Compare commits
157 Commits
1aa51ca943
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a14449ef85 | |||
| 4343b0901d | |||
| c3e77399ad | |||
| 75426224d1 | |||
| e5ff5373bb | |||
| 9b30c0a490 | |||
| 061f79ecda | |||
| a32a1bb075 | |||
| 32979d7bde | |||
| d15f4b2ab6 | |||
| a221a161f4 | |||
| 264b2c77f0 | |||
| 16ea54e35d | |||
| 81ba6bd1c6 | |||
| 06c1718a52 | |||
| de500f9890 | |||
| 9a151f3d8b | |||
| e165e2a0d3 | |||
| d15900d1eb | |||
| 4cb1fab416 | |||
| 4915bba0b3 | |||
| 96c400f2f2 | |||
| 936b0837bf | |||
| a9cfcb272a | |||
| 163216a68e | |||
| 9156e8a4a9 | |||
| bfb13f44a5 | |||
| f7555a17d1 | |||
| 19f3dc5330 | |||
| 6438beec05 | |||
| 5f01aa89fc | |||
| f146f33f5d | |||
| a448ebc4de | |||
| b4a0dd551a | |||
| 848deb1c24 | |||
| 9230d0dd7a | |||
| c171f8879c | |||
| 148ac494c2 | |||
| 58f4991d92 | |||
| 2a90538473 | |||
| 4c2331563c | |||
| 4cf7acbb96 | |||
| 549727f648 | |||
| 87484d6863 | |||
| be04a9772e | |||
| 2587fe63af | |||
| 87537bf3e5 | |||
| a15623cc70 | |||
| 8ccca90e3c | |||
| 2554c2633b | |||
| 0d8426d353 | |||
| 9fbaed4af8 | |||
| 108368d583 | |||
| 843c54cd1a | |||
| ee123f2a90 | |||
| 0c269a97ee | |||
| 6db64230eb | |||
| 638edc6a9f | |||
| aea26493db | |||
| 8b7bdfe798 | |||
| 4195854bf8 | |||
| a468e4b6a4 | |||
| 4c78914529 | |||
| fd1feaaee3 | |||
| fe12401e34 | |||
| 61c38a0d14 | |||
| 29444805a8 | |||
| b0205c42c9 | |||
| 35cfcbcb07 | |||
| 65edf8369e | |||
| 8f208ed0da | |||
| b76ca5d9be | |||
| ca91e5b693 | |||
| 1eb1c91dd8 | |||
| 6a17af2118 | |||
| 11597eb00c | |||
| b546c79b9e | |||
| a1aabd591d | |||
| 8394de6926 | |||
| 574354131c | |||
| ef0a574a70 | |||
| 883c1c290b | |||
| e0cb7b3ddc | |||
| 5cd1876c90 | |||
| b526a0a033 | |||
| 8bb075c656 | |||
| c065967f4d | |||
| e84afc1718 | |||
| f449c4ee34 | |||
| 0935766f61 | |||
| f74f4e54f6 | |||
| 0832ef777c | |||
| 6a082cf77c | |||
| 46a8f50c34 | |||
| 3f47b504c2 | |||
| 71c618073e | |||
| 20cdfedb85 | |||
| b1d9ece9b2 | |||
| b337525bed | |||
| 4fb29d16cf | |||
| 57171b4c88 | |||
| 3aef344b95 | |||
| dc1ceda0c2 | |||
| 2de0899ac6 | |||
| 47d07d6168 | |||
| 4ba02047db | |||
| fe1387b1ec | |||
| 67a4a76afa | |||
| b130eeb967 | |||
| bf80f9dfd4 | |||
| e0864828c2 | |||
| 60abf42359 | |||
| e18315814c | |||
| 9b19b74d39 | |||
| 9bc8ad11b2 | |||
| 0f20d9d8f4 | |||
| f0a8455fc2 | |||
| 3643f74eb1 | |||
| b8162753a9 | |||
| bd00d4c728 | |||
| 90b708302e | |||
| 7004d798d6 | |||
| 7b6f836475 | |||
| fdbe1748e5 | |||
| 404a002d5c | |||
| 3987c31b1e | |||
| 76b4286ad3 | |||
| 3a4eb25392 | |||
| 325d95af6f | |||
| 8eb5653848 | |||
| e7a7d5fdb4 | |||
| c9270d08e1 | |||
| 5778ac4645 | |||
| ab75134931 | |||
| 5d9be4d046 | |||
| 13c1ae6064 | |||
| e032a9df1c | |||
| 048562a6ba | |||
| 731ed316df | |||
| 6517644d1f | |||
| 6b7d391737 | |||
| 37711f87c1 | |||
| 07be54e62a | |||
| 85f352b14f | |||
| 241ce3ec78 | |||
| f2d5961c33 | |||
| 90eb17d407 | |||
| 3d384c1e86 | |||
| 7652afe955 | |||
| b584cc29ad | |||
| e350523f7e | |||
| dfef585ec8 | |||
| 06cab545da | |||
| ef137b64cd | |||
| 2474b683e2 | |||
| 009898efae | |||
| bd6409038e |
+56
-5
@@ -1,15 +1,31 @@
|
||||
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
||||
|
||||
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
||||
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
|
||||
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
|
||||
POSTGRES_PASSWORD=rippy
|
||||
|
||||
# JWT-Signierschlüssel — PFLICHT, API startet sonst nicht.
|
||||
# Erzeugen: openssl rand -hex 32
|
||||
JWT_SECRET_KEY=
|
||||
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||
# generischen sg-Knoten des Laufwerks.
|
||||
#
|
||||
# BEWUSST AUSKOMMENTIERT: Die sg-Nummer ist JE HOST ANDERS. Ein vorbelegter Wert
|
||||
# wäre auf den meisten Rechnern falsch — und zwar unbemerkt, weil er dann „schon
|
||||
# gesetzt" aussieht. `install.sh` findet beide Knoten selbst (Abgleich über die
|
||||
# SCSI-Adresse in /sys) und trägt sie hier ein.
|
||||
#
|
||||
# Von Hand ermitteln, falls gewünscht: lsscsi -g
|
||||
# Ohne Eintrag gelten die Compose-Vorgaben /dev/sr0 und /dev/sg1.
|
||||
#OPTICAL_SR=/dev/sr0
|
||||
#OPTICAL_SG=/dev/sg1
|
||||
|
||||
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
|
||||
|
||||
# Metadaten-APIs
|
||||
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
||||
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
|
||||
# später im UI setzbar): kostenlos auf themoviedb.org
|
||||
TMDB_API_KEY=
|
||||
# TVDb (optional, Serien-Fallback)
|
||||
THETVDB_API_KEY=
|
||||
@@ -19,4 +35,39 @@ OMDB_API_KEY=
|
||||
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
|
||||
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
|
||||
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
|
||||
# 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-Datenverzeichnis auf dem HOST (bleibt über Rebuilds hinweg erhalten).
|
||||
# Hier hinein gehört die KEYDB.cfg für 4K-UHD-Discs; hier landen auch die
|
||||
# AACS-Dumps fehlgeschlagener Discs. Beides ist ab Einstellungen → System
|
||||
# im Browser erreichbar — dieser Pfad ist nur für den Fall, dass du die
|
||||
# Dateien direkt auf der Maschine anfassen willst.
|
||||
#MAKEMKV_DATA_HOST=/srv/rippy/makemkv
|
||||
|
||||
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
|
||||
# Update: Version hier anheben, dann auf der Rippy-Maschine
|
||||
# docker compose build worker && docker compose up -d worker
|
||||
# (bei Cloudflare-Zicken vorher Tarballs nach docker/worker/vendor/ legen)
|
||||
#MAKEMKV_VERSION=1.18.4
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MakeMKV-Bezug beim Image-Bau (nur nötig, wenn der Download klemmt)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern:
|
||||
# https://www.makemkv.com/download HTTP 200 <- der Standard, funktioniert
|
||||
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
|
||||
# web.archive.org-Schnappschuss HTTP 404
|
||||
# Normalerweise ist hier NICHTS einzutragen.
|
||||
#MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
||||
#
|
||||
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird.
|
||||
# Trage hier nur etwas ein, das du selbst geprüft hast — eine Adresse, die nicht
|
||||
# liefert, lässt die Konfiguration gesund aussehen und den Bau später scheitern.
|
||||
# Genau so lag es auf der Rippy-VM: dort stand eine 404-Adresse, und nur die
|
||||
# vendor-Tarballs retteten jeden Bau, ohne dass es jemandem auffiel.
|
||||
#MAKEMKV_URL_FALLBACK=http://192.168.178.10:8099
|
||||
#
|
||||
# Der zuverlässigste Weg bleibt ohne Netz: Tarballs von makemkv.com/download
|
||||
# laden und nach docker/worker/vendor/ legen — der Bau nimmt sie dann von dort.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
||||
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
||||
*.sh text eol=lf
|
||||
*.exe binary
|
||||
*.ico binary
|
||||
|
||||
@@ -72,13 +72,12 @@ jobs:
|
||||
fi
|
||||
exit $rot
|
||||
|
||||
- name: Grün-Gate — grünen Stand auf 'stable' befördern (Arcane deployt von dort)
|
||||
- name: Ergebnis
|
||||
if: success()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ]; then
|
||||
git push origin +HEAD:refs/heads/stable
|
||||
echo "✅ main → stable befördert. Arcane-GitSync zieht 'stable' und deployt."
|
||||
else
|
||||
echo "Kein main-Push — keine Beförderung."
|
||||
fi
|
||||
# Single Source of Truth: 'main' (24.07.2026, Commander-Entscheid —
|
||||
# der stable-Zwischenbranch ist weg). Die Ampel PRÜFT nur noch und
|
||||
# befördert nichts; deployt wird direkt aus 'main' (git pull auf der
|
||||
# VM bzw. ./deploy.sh). Rot heißt weiterhin: nicht deployen.
|
||||
echo "✅ AMPEL GRÜN. Deploy-Quelle ist 'main' — von dort deployen."
|
||||
|
||||
@@ -17,10 +17,12 @@ abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel,
|
||||
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
||||
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
||||
|
||||
**C. Deployment läuft AUTOMATISCH über das Grün-Gate.** Ampel grün auf `main` →
|
||||
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und
|
||||
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist
|
||||
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen.
|
||||
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
||||
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
||||
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
||||
|
||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||
@@ -53,9 +55,118 @@ 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 ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||
|
||||
## Aktueller Stand (21.07.2026)
|
||||
## Aktueller Stand (26.07.2026)
|
||||
|
||||
- ✅ **Etappe 8 abgeschlossen:** Dark Mode mit Theme-Toggle, React-UI mit TailwindCSS
|
||||
- ✅ **Git Sync:** Arcane VM (192.168.178.162) mit Git Repository konfiguriert
|
||||
- ✅ **Savepoint v1.8:** Separation of Concerns geplant, Dark Mode implementiert
|
||||
- 📝 **Nächste Etappe:** SoC-Refactoring (Theme-Context auslagern)
|
||||
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
||||
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
||||
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
||||
- ✅ **Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
|
||||
MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI
|
||||
- ✅ **Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
|
||||
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
|
||||
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
|
||||
- ✅ **Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
|
||||
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
|
||||
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
|
||||
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt
|
||||
- ✅ **Etappe 20 (v3.15):** Aufräum-Runde — `/capabilities` 1,010 s → 0,003 s
|
||||
(Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
|
||||
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
|
||||
- ✅ **Etappe 21 (v3.17):** Der Blocker externes Encoden ist zu —
|
||||
`RIPPY_PATH_MAP` leitet Rippy aus seinen eigenen Mounts ab, der Installer holt
|
||||
es selbst; Presets kommen vom Worker statt aus dem Quelltext; Dashboard ohne
|
||||
Placebos, mit Restzeit. Dazu vier Bestandsfehler, alle live gemessen (u. a.
|
||||
„Neu komprimieren" ging nie, und `os.path.isdir` hing im Kernel)
|
||||
- ✅ **Etappe 22 (v3.18):** Auswurf wirkt endlich (MakeMKV verriegelt die Tür —
|
||||
erst entriegeln, dann prüfen statt glauben), externer Worker meldet sein Log
|
||||
nach Rippy, zeigt den laufenden Job und nimmt mehrere Aufträge an. Dazu **die
|
||||
Mount-Ursache**: Die CIFS-Verbindung lebt in der Netz-Namespace des
|
||||
api-Containers und stirbt mit ihm — eine Wache heilt das jetzt selbst
|
||||
- ✅ **Etappe 23 (v3.19):** Sprachwahl vor dem Rip (Ton + Untertitel, Automatik
|
||||
einstellbar), Auswurf wirkt wirklich (MakeMKV verriegelt die Tür), externer
|
||||
Worker mit Verwaltungsfenster/Deinstaller/Slots, Schlüssel-Automatik für 4K,
|
||||
Mount-Wiederanbindung 202 s → 8 s, Weitergabe an einem frischen Klon geprüft
|
||||
- ✅ **Etappe 24 (v3.20):** Rippy bremste sich selbst aus — das Rate-Limit lag
|
||||
unter der eigenen Last (100/min gegen 123/min), hinter dem Proxy teilten alle
|
||||
Clients einen Eimer, und ein abgewiesener Abruf leerte das UI. Dazu: der
|
||||
„Neu"-Knopf kennt jetzt die Phase (Rip oder Kompression) und fragt, wo er es
|
||||
nicht weiß; zwei Fehler im Deploy-Weg behoben
|
||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||
|
||||
## Was diese Sitzungen wiederholt gekostet hat
|
||||
|
||||
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
|
||||
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
|
||||
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99` →
|
||||
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev` →
|
||||
„`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
|
||||
Jedes Mal hätte eine Messung von unter einer Minute gereicht.
|
||||
|
||||
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
|
||||
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
|
||||
vorherzusagen.
|
||||
|
||||
**Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der
|
||||
laut scheitert** (26.07.2026). Ein `except Exception: pass` in einer
|
||||
Vorrats-Schleife hat eine Stunde gekostet: Der Vorrat blieb leer, die Funktion
|
||||
lief direkt aufgerufen einwandfrei, und der Grund stand nirgends. Gefunden erst
|
||||
über die Thread-Zustände (`/proc/<pid>/task/*/stat`, Zustand `D` = im Kernel
|
||||
blockiert). Jede Hintergrund-Schleife MELDET ihren Fehler, und wer einen Vorrat
|
||||
anlegt, macht sein Alter abfragbar (`GET /health/vorraete`) — sonst ist am
|
||||
Endpunkt selbst nichts zu sehen.
|
||||
|
||||
**Ein Rückgabewert ist kein Beweis, wo die Wirkung prüfbar ist** (26.07.2026,
|
||||
zweimal am selben Abend). `CDROMEJECT` quittiert Erfolg auf einem verriegelten
|
||||
Laufwerk und wirft nichts aus; `mount` quittiert Erfolg auf einer Verbindung, die
|
||||
Sekunden später stirbt. Beide Fehler waren monatelang unsichtbar, weil der Code
|
||||
dem Rückgabewert glaubte. Nach einer Aktion den ZUSTAND fragen — und wenn er
|
||||
flattert, zweimal mit Abstand.
|
||||
|
||||
**Bei „zu langsam" die DAUER je Schritt messbar machen, nicht die plausibelste
|
||||
Ursache beheben** (26.07.2026). Eine Mount-Wiederanbindung brauchte 150 s; die
|
||||
erste, sehr plausible Erklärung war falsch, und die Änderung machte es langsamer
|
||||
(202 s). Erst Zeitstempel im Log zeigten die Stelle: ein `os.makedirs` in Zeile
|
||||
eins, das drei Minuten im Kernel hing. Danach 8 s. Wer eine Dauer nicht
|
||||
aufschlüsselt, optimiert die falsche Stelle.
|
||||
|
||||
**Netz-Pfade nie ungebremst anfassen.** `os.path.isdir`/`open` auf einem toten
|
||||
CIFS-Mount blockieren im Kernel und lassen sich aus Python NICHT abbrechen. Ein
|
||||
Kind-Prozess lässt sich abbrechen: `timeout N ls -d <pfad>` (Muster in
|
||||
`mounts.ist_erreichbar` und `rohdaten.verzeichnis_da`). Und „konnte nicht
|
||||
nachsehen" ist etwas anderes als „ist nicht da" — beides zu vermischen erzeugt
|
||||
falsche Aussagen im UI.
|
||||
|
||||
**Ein verpasster Abruf ist keine Nachricht über die Welt** (26.07.2026). Im UI
|
||||
stand fünfmal `catch(() => [])`: Jeder fehlgeschlagene Abruf hieß damit „es gibt
|
||||
keine Jobs, keine Laufwerke, keine Ablagen" — die Liste leerte sich für einen
|
||||
Takt und füllte sich vier Sekunden später wieder. Der Commander meldete das als
|
||||
„wird oft neu geladen", und die Ursache war unsichtbar, weil der Fehlerzweig
|
||||
nichts protokollierte. Wer nichts Neues weiß, behält, was er wusste: bei
|
||||
Fehlschlag `null` und den alten Stand stehen lassen — nie einen leeren Wert, der
|
||||
als Aussage gelesen wird.
|
||||
|
||||
**Eine eigene Schutzbremse gegen die eigene Last rechnen** (26.07.2026). Das
|
||||
Rate-Limit stand auf 100 Anfragen/min, während ein einziger offener Tab 111/min
|
||||
verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12). Rippy bremste sich
|
||||
also permanent selbst aus, und niemand sah es: Der 429 stand in keinem Log, und
|
||||
das UI verbuchte ihn als Leermeldung. Dazu der zweite Fehler — hinter einem
|
||||
Reverse-Proxy ist `request.client.host` IMMER der Proxy, also hatten Browser,
|
||||
zweiter Tab und Windows-Tray EINEN gemeinsamen Eimer (812 von 876 Anfragen kamen
|
||||
scheinbar von einer IP). Wer eine Grenze setzt, rechnet die eigene Grundlast vor,
|
||||
schreibt sie als Kommentar dazu und lässt jedes Greifen protokollieren.
|
||||
|
||||
**Eine geschluckte Warnung ist eine Falle** (26.07.2026). `cp "$ENV_SRC" .env
|
||||
2>/dev/null || echo "WARNUNG: …"` scheiterte auf der Ziel-VM bei JEDEM Deploy,
|
||||
weil die .env dort anders lag. Die Zeile scrollte im Build-Rauschen vorbei,
|
||||
gebaut wurde still mit einer zwei Tage alten Kopie — mit einem toten
|
||||
Download-Notbehelf darin, an dem jeder worker-Build abbrach. Entweder abbrechen
|
||||
oder so laut werden, dass es nicht zu übersehen ist (Dateidatum, Kandidatenliste);
|
||||
ein `|| echo` in einem 200-Zeilen-Log ist keins von beidem.
|
||||
|
||||
**Wenn der Commander eine Korrelation nennt, ist das eine Spur.** „Wenn der
|
||||
Worker installiert ist, wird der Bereich oft neu geladen" klang nach Bauchgefühl
|
||||
und war exakt richtig: `tray.py` fragt `/api/jobs` über Port 80, landet damit im
|
||||
Rate-Limit-Eimer des Browsers und drückt ihn über die Grenze. Dieselbe Lehre wie
|
||||
bei „auf Windows ginge das sofort" (Disc-Schlüssel) — die Beobachtung ernst
|
||||
nehmen, auch wenn die vermutete Erklärung („Celery-Ping?") daneben liegt.
|
||||
|
||||
+68
-4
@@ -35,8 +35,8 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
|
||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||
| JWT-Auth (Access 15min/Refresh 7 Tage) | ✔ M | | |
|
||||
| Rate-Limiting (100/min pro API-Key) | ✔ M | | |
|
||||
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
||||
| Rate-Limiting pro Client-IP — ~~100/min~~ **600/min** (26.07.2026, siehe Abweichungen) | ✔ M | | |
|
||||
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
||||
@@ -44,7 +44,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| MakeMKV als isolierte Black-Box-CLI | ✔ M | | |
|
||||
| GPL-v3-Compliance-Checkliste | ✔ M | | |
|
||||
| Source-Release-Endpoint | ✔ M | | |
|
||||
| HandBrake-Transcoding (optional) | | ✔ K | |
|
||||
| HandBrake-Kompression NACH dem Lossless-Rip (Stufe 2) | ✔ M | | |
|
||||
| Prometheus+Grafana Monitoring | | ✔ K | |
|
||||
| PBS-Snapshot-Backup-Hooks | | ✔ K | |
|
||||
| Multi-Disc-Parallelisierung | | | ✔ S |
|
||||
@@ -68,7 +68,12 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
- Commander bestätigt oder korrigiert manuell.
|
||||
6. **Ripping** (nach Bestätigung):
|
||||
- CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz.
|
||||
- DVD/Blu-ray: `makemkvcon --all --progress` → MKV.
|
||||
- DVD/Blu-ray: `makemkvcon` → verlustfreies MKV als ZWISCHENPRODUKT in /app/temp
|
||||
(MakeMKV ist der einzige Weg durch AACS — HandBrake kann verschlüsselte
|
||||
Discs nicht lesen), danach **HandBrake-Kompression auf Arbeitsgröße**
|
||||
(x265; Commander-Entscheid 23.07.2026: 40-GB-Rohdateien sind kein
|
||||
brauchbares Endprodukt). Roh-Datei wird nach Erfolg gelöscht
|
||||
(Setting keepOriginal behält sie).
|
||||
7. **Post-Processing & Jellyfin-Formatierung**:
|
||||
- Dateien in Jellyfin-konformer Ordnerstruktur.
|
||||
- NFO-Dateien im Kodi/NFO-Schema.
|
||||
@@ -122,6 +127,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| Risiko | Status | Behandlung |
|
||||
|--------|--------|------------|
|
||||
| MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE |
|
||||
| **Disc-Schlüssel für 4K-UHD (AACS 2.0)** | **Gelöst mit Handgriff** | Am 25.07.2026 auf beiden Maschinen gemessen: `makemkvcon` unter **Linux** ruft Disc-Schlüssel nie ab (kein einziger Verbindungsversuch, mit leerem wie gefülltem Speicher, mit und ohne `--noscan`, `dev:` wie `disc:`), die **Windows**-Version tut es (Meldung 3338). Rippy stellt ein persistentes Datenverzeichnis bereit und nimmt den Schlüsselspeicher `_private_data.tar` einer Windows-Installation sowie ersatzweise eine `KEYDB.cfg` entgegen; damit ging Akira UHD auf der VM auf. Rippy liefert, lädt und verteilt KEINE Schlüssel. Siehe §10 (25.07.2026) |
|
||||
| LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) |
|
||||
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
|
||||
| Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert |
|
||||
@@ -156,3 +162,61 @@ 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)
|
||||
|
||||
**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.
|
||||
- **24.07.2026 — AUTH GESTRICHEN (Commander-Entscheid):** Das Muss-Feature
|
||||
„JWT-Auth" ist komplett entfernt (Endpoints, auth.py, Abhängigkeiten,
|
||||
Env-Pflicht). Begründung: Rippy läuft ausschließlich im Heimnetz, das UI
|
||||
hatte nie einen Login-Flow — die Auth-Oberfläche war Placebo und die
|
||||
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
||||
- **26.07.2026 — Rate-Limit von 100/min auf 600/min:** Das Muss-Feature
|
||||
(„Rate-Limiting pro Client-IP") bleibt unverändert, nur die Zahl ändert sich.
|
||||
Begründung: 100/min lag UNTER Rippys eigener Last. Nachgerechnet an den
|
||||
Taktgebern im UI verursacht ein einziger offener Tab 111 Anfragen pro Minute
|
||||
(Dashboard 75 + Log-Kasten 24 + Laufwerks-Suche 12), ein installierter
|
||||
Windows-Worker weitere 12. Die Bremse griff also im Normalbetrieb permanent —
|
||||
97 Antworten mit HTTP 429 im nginx-Log —, und weil das UI einen abgewiesenen
|
||||
Abruf als „nichts da" verbuchte, leerte sich die Job-Liste im Sekundentakt.
|
||||
Genau das hat der Commander als „wird oft neu geladen" gemeldet. 600/min =
|
||||
10 Anfragen pro Sekunde: Luft für mehrere Tabs und Worker, während der Zweck
|
||||
der Bremse (ein Skript in einer Endlosschleife, Hunderte pro Sekunde) weiter
|
||||
erfüllt ist. Die Rechnung steht als Kommentar in `ratelimit.py` und ist durch
|
||||
`test_grenze_deckt_die_eigene_last_ab` festgehalten — wer die Zahl senkt, muss
|
||||
dort vorbei. Zweiter Teil derselben Reparatur: Hinter dem nginx ist
|
||||
`request.client.host` immer der Proxy, alle Clients teilten sich also EINEN
|
||||
Eimer; jetzt gilt `X-Real-IP`.
|
||||
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
|
||||
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
||||
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
||||
(`MAKEMKV_DATA_HOST`, im Worker `/root/.MakeMKV`, in der API
|
||||
`/app/makemkv-data`) samt Bedienung im UI. Begründung: Am 25.07.2026 auf
|
||||
BEIDEN Maschinen nachgemessen — `makemkvcon` unter Linux ruft Disc-Schlüssel
|
||||
nie ab, die Windows-Version tut es. (Erste Fassung dieses Eintrags behauptete,
|
||||
MakeMKVs Schlüssel-Kanal sei abgeschaltet; das war falsch und wurde am selben
|
||||
Tag richtiggestellt.) Rippy nimmt deshalb den Schlüsselspeicher
|
||||
`_private_data.tar` einer MakeMKV-Installation entgegen und ersatzweise eine
|
||||
`KEYDB.cfg`. **Rippy liefert und verteilt KEINE Disc-Schlüssel und
|
||||
lädt auch keine herunter** — es hält nur den Platz für Dateien bereit,
|
||||
die der Nutzer selbst mitbringt, zeigt ehrlich an, was dort liegt, und gibt
|
||||
die AACS-Dumps heraus, die MakeMKV ohnehin selbst schreibt. Das ist genau
|
||||
die Grenze, die `docker/api/makemkv_key.py` in Zeile 14 zieht: die
|
||||
kostenlose Beta-LIZENZ der Software ist etwas anderes als das
|
||||
Entschlüsseln oder Verteilen von Disc-Schlüsseln.
|
||||
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
|
||||
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||
EINDEUTIGER Zuordnung wird umbenannt).
|
||||
|
||||
@@ -1,119 +1,475 @@
|
||||
# README — Rippy
|
||||
# Rippy — die All-in-one Disc-Ripping-Maschine
|
||||
|
||||
> **Moderner Ripping-Daemon mit Metadaten-Preview, Jellyfin-Formatierung und JWT-Auth.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Disc-Erkennung (CD/DVD/Blu-ray) | ✅ |
|
||||
| Metadaten-Lookup (TMDB/MusicBrainz/TheTVDB) | ✅ |
|
||||
| Pre-Scan ohne Ripping | ✅ |
|
||||
| Jellyfin-Formatierung (NFO + Images) | ✅ |
|
||||
| JWT-Auth + Rate-Limiting | ✅ |
|
||||
| React-UI mit Dashboard | ✅ |
|
||||
| SQLite-Cache für API-Rate-Limits | ✅ |
|
||||
| Multi-Disc-Set-Handling | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Proxmox LXC (Debian 12) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ API │ │ Worker │ │ UI │ │
|
||||
│ │ FastAPI │◄─►│ Celery+ │ │ React+ │ │
|
||||
│ │ │ │ Redis │ │ Nginx │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ PostgreSQL│ │ udev │ │ Media │ │
|
||||
│ │ │ │ Daemon │ │ Store │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
Disc rein → automatisch erkannt (Titel, Poster, Metadaten) → verlustfrei
|
||||
gerippt (MakeMKV) → auf Arbeitsgröße komprimiert (HandBrake) → fertig
|
||||
abgelegt, wo du willst (lokal, NAS, jede Freigabe). Modernes Web-UI, komplett
|
||||
in Docker, komplett lokal — keine Cloud, keine Telemetrie.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker Compose
|
||||
Du brauchst: einen **Linux-Rechner mit Docker** und ein **optisches Laufwerk**.
|
||||
|
||||
**Welche Distribution, ist gleichgültig** — Debian, Ubuntu, Arch, Fedora,
|
||||
openSUSE, Alpine. Rippy bringt alles Nötige (MakeMKV, HandBrake, abcde) in
|
||||
seinen eigenen Containern mit; vom Host braucht es nur Docker und einen Kernel,
|
||||
der das Laufwerk sieht. `install.sh` erkennt das Paketwerkzeug selbst und nennt
|
||||
dir den passenden Befehl, falls etwas fehlt.
|
||||
|
||||
```bash
|
||||
git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git
|
||||
cd rippy
|
||||
docker compose up -d
|
||||
git clone <repo-url> rippy && cd rippy
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
### Port-Übersicht
|
||||
Das war es. Am Ende steht die Adresse im Terminal — im Browser öffnen, der
|
||||
**Einrichtungs-Assistent** übernimmt den Rest.
|
||||
|
||||
| Service | Port | URL |
|
||||
|---------|------|-----|
|
||||
| UI | 80 | http://localhost:80 |
|
||||
| API | 8000 | http://localhost:8000 |
|
||||
| PostgreSQL | 5432 | localhost:5432 |
|
||||
| Redis | 6379 | localhost:6379 |
|
||||
Zuerst nur nachsehen, ob alles passt, ohne etwas zu ändern:
|
||||
|
||||
```bash
|
||||
./install.sh --nur-pruefen
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Was <code>install.sh</code> für dich macht</b> (aufklappen)</summary>
|
||||
|
||||
Es nimmt genau die Schritte ab, an denen man vorher scheitern konnte:
|
||||
|
||||
1. **Prüft die Voraussetzungen** — Linux, Docker, Compose. Fehlt etwas, steht
|
||||
der Installationsbefehl dabei.
|
||||
2. **Findet dein Laufwerk selbst.** MakeMKV braucht **zwei** Geräteknoten:
|
||||
`/dev/srN` und den passenden `/dev/sgM`. Welche sg-Nummer dazugehört, ist
|
||||
**je Rechner anders** — das Skript gleicht sie über die SCSI-Adresse ab
|
||||
statt zu raten. (Genau daran ging vorher die Handarbeit schief.)
|
||||
3. **Legt die Verzeichnisse an** (`/srv/rippy/media` für die Filme,
|
||||
`/srv/rippy/makemkv` für MakeMKVs Daten).
|
||||
4. **Richtet die Mount-Propagation ein** und macht sie neustart-fest — sonst
|
||||
funktioniert das Einhängen von NAS-Freigaben aus dem UI nach jedem Reboot
|
||||
nicht mehr.
|
||||
5. **Schreibt die `.env`** mit den gefundenen Werten. Bestehende Werte werden
|
||||
**nie** überschrieben.
|
||||
6. **Baut und startet** alles.
|
||||
|
||||
Das Skript ist **wiederholbar**: mehrmals ausführen ändert nichts kaputt. Es
|
||||
ist auch der Update-Weg — siehe unten.
|
||||
|
||||
</details>
|
||||
|
||||
### Aktualisieren
|
||||
|
||||
```bash
|
||||
git pull && sudo ./install.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
## Mit einer Docker-Oberfläche (Dockge, Portainer, Arcane)
|
||||
|
||||
### Endpoints
|
||||
**Die eine Sache, die du wissen musst:** Rippy hat **keine fertigen Images in
|
||||
einer Registry.** Die drei Dienste `api`, `worker` und `ui` werden aus dem Repo
|
||||
gebaut (`build: context: .`). Die Compose-Datei allein ist deshalb wertlos —
|
||||
wer sie in ein leeres Verzeichnis kopiert, bekommt sofort
|
||||
*„failed to read dockerfile"*.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/jobs` | GET | Alle Jobs |
|
||||
| `/devices` | GET | Alle Geräte |
|
||||
| `/prescan` | POST | Pre-Scan durchführen |
|
||||
| `/metadata/lookup` | POST | Metadaten lookup |
|
||||
| `/metadata/confirm` | POST | Metadaten bestätigen |
|
||||
| `/jellyfin/format` | POST | Für Jellyfin formatieren |
|
||||
| `/token` | POST | Login (JWT) |
|
||||
| `/token/refresh` | POST | Refresh Token |
|
||||
| `/api-keys` | POST/GET/DELETE | API-Key Management |
|
||||
Daraus folgt für **jede** Oberfläche: **das Repo muss dort liegen, wo die
|
||||
Oberfläche den Stack baut.** Alles andere ist Kleinarbeit.
|
||||
|
||||
| | kann das Repo selbst holen? | Weg |
|
||||
|---|---|---|
|
||||
| **Shell** | — | `git clone` + `sudo ./install.sh` |
|
||||
| **Dockge** | nein | Repo **in** den Stacks-Ordner klonen |
|
||||
| **Portainer** | **ja** | Stack aus *Repository* (Git-URL) |
|
||||
| **Arcane** | nein | Repo auf dem Host, Deploy per Shell |
|
||||
|
||||
### Vorab-Prüfung — braucht kein root, ändert nichts
|
||||
|
||||
Egal welche Oberfläche: Führe nach dem Klonen einmal das hier aus. Es sagt dir
|
||||
in fünf Sekunden, ob die Maschine passt — vor allem, **welche Geräteknoten dein
|
||||
Laufwerk wirklich hat**:
|
||||
|
||||
```bash
|
||||
./install.sh --nur-pruefen
|
||||
```
|
||||
|
||||
### Dockge
|
||||
|
||||
Dockge verwaltet Stacks als Verzeichnisse. Klone das Repo **in** den
|
||||
Stacks-Ordner (Standard `/opt/stacks`, bei dir ggf. anders):
|
||||
|
||||
```bash
|
||||
cd /opt/stacks
|
||||
git clone <repo-url> rippy
|
||||
cd rippy && ./install.sh --nur-pruefen
|
||||
```
|
||||
|
||||
Dockge zeigt `rippy` danach als Stack an, und „Start" baut die Images. Du musst
|
||||
**keine Compose-Datei einfügen** — die des Repos ist die des Stacks. Lege den
|
||||
Stack also **nicht** neu in Dockge an, sonst landet eine leere Compose-Datei in
|
||||
einem anderen Ordner und der Bau scheitert.
|
||||
|
||||
### Portainer
|
||||
|
||||
Portainer kann das Repo selbst klonen, das ist hier der bequemste Weg:
|
||||
**Stacks → Add stack → Repository**, Git-URL eintragen, Compose-Pfad
|
||||
`docker-compose.yml`. Der Web-Editor und „Upload" funktionieren **nicht** —
|
||||
beide liefern keinen Build-Kontext.
|
||||
|
||||
Die Geräteknoten trägst du als Stack-Umgebungsvariablen ein (statt in eine
|
||||
`.env`), falls sie von `/dev/sr0` und `/dev/sg1` abweichen:
|
||||
|
||||
```
|
||||
OPTICAL_SR=/dev/sr0
|
||||
OPTICAL_SG=/dev/sg0
|
||||
```
|
||||
|
||||
Welche es sind, sagt dir auf dem Host `lsscsi -g` — oder die Vorab-Prüfung oben.
|
||||
|
||||
### Arcane
|
||||
|
||||
Das Repo liegt auf dem Host (z. B. `~/projects/rippy`), Arcane verwaltet die
|
||||
Container. Deployt wird per Shell — Arcanes Git-Sync zieht **nicht**
|
||||
selbstständig:
|
||||
|
||||
```bash
|
||||
cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build
|
||||
```
|
||||
|
||||
### Was keine Oberfläche für dich tun kann
|
||||
|
||||
Drei Dinge passieren auf dem **Host**, nicht im Container — deshalb gibt es
|
||||
`install.sh` überhaupt:
|
||||
|
||||
1. **Geräteknoten.** MakeMKV braucht `/dev/srN` **und** den passenden
|
||||
`/dev/sgM`; die sg-Nummer ist je Rechner anders. Stimmt sie nicht, **startet
|
||||
der Worker-Container gar nicht.** Das ist der Fehler, der praktisch immer als
|
||||
erster kommt.
|
||||
2. **Ablage-Ordner** `/srv/rippy/media` und `/srv/rippy/makemkv`.
|
||||
3. **Mount-Propagation** (`rshared`) für das Einhängen von NAS-Freigaben aus dem
|
||||
UI. Auf den meisten systemd-Hosts ist `/` schon `rshared` und es ist nichts
|
||||
zu tun — nur wenn `docker compose up` über *„not a shared mount"* klagt,
|
||||
braucht es die Shell.
|
||||
|
||||
Punkt 1 ist der einzige, der zuverlässig zuschlägt. `sudo ./install.sh` erledigt
|
||||
alle drei; danach kannst du den Stack dauerhaft über die Oberfläche fahren.
|
||||
|
||||
---
|
||||
|
||||
## Tech-Stack
|
||||
## Wenn etwas nicht geht
|
||||
|
||||
| Layer | Tech |
|
||||
|-------|------|
|
||||
| Backend | Python, FastAPI, Celery |
|
||||
| Database | PostgreSQL, SQLite, Redis |
|
||||
| Frontend | React, Vite, TailwindCSS |
|
||||
| Ripping | MakeMKV, abcde, FFmpeg, HandBrake |
|
||||
| Auth | JWT, OAuth2 |
|
||||
Die sechs Fälle, die praktisch alles abdecken:
|
||||
|
||||
| Symptom | Ursache & Lösung |
|
||||
|---|---|
|
||||
| **`failed to read dockerfile`** | Die Compose-Datei liegt ohne das Repo da. Rippy hat **keine Registry-Images**, die drei Dienste werden gebaut. Siehe **[Mit einer Docker-Oberfläche](#mit-einer-docker-oberfläche-dockge-portainer-arcane)**. |
|
||||
| **Worker-Container startet nicht, `/dev/sgN` nicht gefunden** | Die sg-Nummer ist **je Rechner anders**; die Vorgaben `sr0`/`sg1` passen nur zufällig. `./install.sh --nur-pruefen` sagt dir die richtigen (oder `lsscsi -g`), dann in die `.env` bzw. als Stack-Variablen eintragen. **Der Fehler, der praktisch immer als erster kommt.** |
|
||||
| **„Kein optisches Laufwerk gefunden"** | In einer **VM**? Das Laufwerk muss per **USB-Passthrough** durchgereicht werden, nicht als emuliertes CD-ROM (`media=cdrom`) — das kann keine SCSI-Kommandos, MakeMKV sieht es nie. Proxmox: `qm set <vmid> -usb0 host=<hersteller>:<produkt>,usb3=1`. Auf echter Hardware: `ls /dev/sr*` prüfen. |
|
||||
| **Build bricht beim MakeMKV-Download ab** | Cloudflare drosselt manchmal. Der zuverlässige Weg: Tarballs von makemkv.com/download händisch nach `docker/worker/vendor/` legen, dann `sudo ./install.sh` erneut — der Build nimmt sie von dort und braucht kein Netz. Hast du eine eigene Quelle (Spiegel im LAN), trage sie als `MAKEMKV_URL_FALLBACK` in die `.env` ein; sie wird automatisch versucht, wenn makemkv.com nicht liefert. |
|
||||
| **Kompression läuft ewig** | Deine CPU kann kein AVX2. Rippy zeigt das jetzt selbst an (Einstellungen → System, „Vektorbefehle"). Siehe **[Rippy schneller machen](#rippy-schneller-machen)**. |
|
||||
| **4K-UHD: „The volume key is unknown"** | Erwartbar und **kein Fehler in Rippy**. Siehe **[4K-UHD](#4k-uhd)**. |
|
||||
|
||||
Logs ansehen: `docker compose logs -f` · Status: `docker compose ps`
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
## Rippy schneller machen
|
||||
|
||||
- [x] Etappe 1: Container-Infrastruktur
|
||||
- [x] Etappe 2: Ripping-Pipeline
|
||||
- [x] Etappe 3: Metadaten-Lookup + Pre-Scan
|
||||
- [x] Etappe 4: Jellyfin-Formatierung
|
||||
- [x] Etappe 5: API + Auth + WebUI
|
||||
- [x] Etappe 6: Sicherheit + Compliance
|
||||
- [x] Etappe 7: API UI Modernisiert
|
||||
- [x] Etappe 8: Dark Mode & Separation of Concerns
|
||||
- [ ] Etappe 9: Proxmox-Integration
|
||||
**Läuft Rippy in einer virtuellen Maschine, ist das hier der wirksamste
|
||||
Handgriff überhaupt** — und er kostet fünf Minuten.
|
||||
|
||||
Virtualisierer geben der VM standardmäßig eine **generische CPU** (bei
|
||||
Proxmox/KVM heißt sie `kvm64` bzw. `qemu64`). Die kann absichtlich nur alte
|
||||
Befehlssätze, damit sich eine VM zwischen verschiedenen Wirten verschieben
|
||||
lässt. Der Preis: **kein AVX2** — und genau davon lebt der Video-Encoder x265.
|
||||
|
||||
Was das ausmacht, ist auf der Rippy-Maschine gemessen worden: ein 4K-Film in
|
||||
H.265 brauchte **28 bis 55 Stunden**, bei voll ausgelasteten Kernen. Mit AVX2
|
||||
sind typisch **2- bis 4-mal schneller** drin.
|
||||
|
||||
**So stellst du es um (Proxmox):**
|
||||
|
||||
1. VM **herunterfahren.** Ein Neustart von innen genügt nicht — Proxmox
|
||||
übernimmt Hardware-Änderungen nur bei einem echten Stopp.
|
||||
2. Im Web-UI: VM auswählen → **Hardware** → **Processors** → Doppelklick →
|
||||
**Type** auf **`host`** stellen → OK.
|
||||
Oder auf der Proxmox-Konsole:
|
||||
```bash
|
||||
qm set <vmid> --cpu host
|
||||
```
|
||||
3. VM **starten.**
|
||||
|
||||
**Nachprüfen — Rippy sagt es dir selbst:** Einstellungen → System, Zeile
|
||||
„Vektorbefehle" beim Worker. Steht dort jetzt `avx2` oder `avx512f` statt
|
||||
`sse4_2`, hat es geklappt und die amberfarbene Warnung verschwindet.
|
||||
Auf der Kommandozeile: `grep -o avx2 /proc/cpuinfo | head -1`.
|
||||
|
||||
<details>
|
||||
<summary>Nachteile von <code>host</code> — der Vollständigkeit halber</summary>
|
||||
|
||||
Die VM sieht dann die echte CPU. Dadurch lässt sie sich nicht mehr auf einen
|
||||
Wirt mit anderem Prozessor **live** verschieben. In einem Heim-Cluster mit
|
||||
einem einzigen Wirt ist das ohne Bedeutung. Wer mehrere Wirte hat und
|
||||
Live-Migration nutzt, wählt statt `host` das neueste Modell, das **alle**
|
||||
Wirte beherrschen (z. B. `x86-64-v3` — das enthält AVX2).
|
||||
|
||||
</details>
|
||||
|
||||
**Kein AVX2 möglich?** Dann zwei Auswege, beide im UI:
|
||||
- **4K nicht komprimieren** (Einstellungen → Verarbeitung → Preset für 4K-UHD →
|
||||
„Nicht komprimieren"). Die verlustfreie Datei bleibt stehen: beste Qualität,
|
||||
20–100 GB je Film. Der Einrichtungs-Assistent wählt das bei schwacher CPU
|
||||
von selbst.
|
||||
- **Andere Maschine komprimieren lassen** — siehe
|
||||
[Weitere Maschinen als Worker](#weitere-maschinen-als-worker).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
## Der Alltag
|
||||
|
||||
GPL-v3
|
||||
### Rippen
|
||||
|
||||
Disc einlegen. Rippy erkennt sie, zeigt Titel und Poster, du wählst das Ziel —
|
||||
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und es
|
||||
läuft ohne Nachfrage los.
|
||||
|
||||
- **Alle Tonspuren und Untertitel bleiben erhalten** (wichtig für Anime/O-Ton).
|
||||
- **Nur Hauptfilm** auf Wunsch — der längste Titel, Extras bleiben weg.
|
||||
- **Serien:** Serienname + Staffel angeben → Ablage `<Serie>/Season NN`, und
|
||||
die Episoden werden per Laufzeitabgleich (TMDB) zu „Serie S01E02.mkv"
|
||||
benannt. Nur bei eindeutiger Zuordnung, sonst bleiben die Namen — mit Log.
|
||||
- **Audio-CDs** laufen über abcde zu FLAC mit MusicBrainz-Tags.
|
||||
- Schon gerippte Discs erkennt Rippy am Fingerabdruck und warnt.
|
||||
- Vor dem Start wird der freie Platz gegen die Disc-Größe geprüft.
|
||||
|
||||
### Kompression
|
||||
|
||||
Ein **eigenes Preset je Disc-Typ** (Einstellungen → Verarbeitung) — Rippy
|
||||
erkennt den Typ selbst. Das ist wichtig, weil ein 1080p-Preset eine 4K-UHD
|
||||
stillschweigend herunterrechnet und eine DVD sinnlos hochskaliert.
|
||||
|
||||
Jeder Typ kann auch auf **„Nicht komprimieren"** stehen: dann bleibt der
|
||||
verlustfreie Rip liegen. Sinnvoll für 4K, wenn die Ablage groß genug ist.
|
||||
|
||||
Die Rohdatei wird erst nach erfolgreicher Kompression gelöscht („Original
|
||||
behalten" als Option). Fehlgeschlagene Kompressionen lassen sich ohne
|
||||
Neu-Rip erneut anstoßen.
|
||||
|
||||
### Ablage & Media-Server
|
||||
|
||||
Einstellungen → Ripping: dein System wählen (Jellyfin, Emby, Kodi, Plex oder
|
||||
keins). Fertige Rips heißen dann „Titel (Jahr)"; für Jellyfin/Emby/Kodi legt
|
||||
Rippy zusätzlich `movie.nfo` + `poster.jpg` dazu. Bei **Jellyfin/Emby** mit
|
||||
Server-URL + API-Key stößt Rippy nach jedem Rip sofort einen
|
||||
Bibliotheks-Scan an — Disc rein, Film erscheint im Server.
|
||||
|
||||
**NAS-Freigaben** hängst du unter Einstellungen → Speicherziele direkt aus dem
|
||||
UI ein (NFS/SMB). Sie erscheinen sofort in der Ziel-Auswahl und werden beim
|
||||
Start automatisch wieder verbunden.
|
||||
⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen Datenbank — bewusster
|
||||
Heimnetz-Kompromiss. Lege fürs NAS einen eigenen, eingeschränkten Benutzer an.
|
||||
|
||||
### Benachrichtigungen
|
||||
|
||||
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen, „Test senden"
|
||||
drücken, speichern. Rippy erkennt den Dienst an der URL selbst:
|
||||
|
||||
| Dienst | URL-Beispiel |
|
||||
|---|---|
|
||||
| Discord | `https://discord.com/api/webhooks/…` |
|
||||
| Slack | `https://hooks.slack.com/services/…` |
|
||||
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` |
|
||||
| Eigenes (Home Assistant, n8n, …) | beliebige HTTPS-URL |
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
## 4K-UHD
|
||||
|
||||
- **Commander**: Projekt-Idee, Anforderungen, Testing
|
||||
- **AI-Box**: Entwicklung, Architektur, Dokumentation
|
||||
4K braucht **zwei** Dinge, die normale BD/DVD nicht brauchen: ein
|
||||
**LibreDrive-fähiges Laufwerk** (MakeMKV-Forum: „Ultimate UHD Drives Flashing
|
||||
Guide") **und** den **Schlüssel dieser Pressung**.
|
||||
|
||||
**Der Schlüssel ist die eigentliche Hürde — aus einem überraschenden Grund.**
|
||||
Am 25.07.2026 auf beiden Maschinen gemessen (Akira UHD, MKB v76, Pressung
|
||||
Dezember 2020): Laufwerk und MakeMKV waren in Ordnung — „Using LibreDrive mode
|
||||
(v06.3)", die Disc wurde gelesen — und trotzdem kam „The volume key is unknown
|
||||
for this disc".
|
||||
|
||||
> **`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz.
|
||||
> Die Windows-Version tut es.**
|
||||
|
||||
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach draußen auf
|
||||
— geprüft mit leerem und gefülltem Schlüsselspeicher, mit und ohne `--noscan`,
|
||||
mit `dev:` und `disc:`, und mit erzwungener frischer Prüfung. Dieselbe Disc am
|
||||
selben Laufwerk unter Windows: „Lade aktuelle HK …", Verbindung zum
|
||||
Schlüssel-Server, Disc geht auf. **Ein MakeMKV-Update ändert daran nichts, und
|
||||
es ist kein Fehler in Rippy.**
|
||||
|
||||
**Der Weg drumherum** (Einstellungen → System):
|
||||
|
||||
1. MakeMKV auf einem **Windows-PC** installieren (gleicher Beta-Key).
|
||||
2. Laufwerk anstecken, Disc **einmal öffnen** — dabei lädt MakeMKV die
|
||||
Schlüssel nach.
|
||||
3. In MakeMKV unter *Preferences → General* das „MakeMKV data directory"
|
||||
nachschlagen und die Datei **`_private_data.tar`** daraus bei Rippy
|
||||
hochladen.
|
||||
|
||||
Rippy zeigt an, **wie viele Disc-Schlüssel** dieser Worker kennt. Steht dort 0,
|
||||
scheitert jede unbekannte UHD. Für neue Discs den Schritt gelegentlich
|
||||
wiederholen. Rippy prüft die Datei und lehnt sie ab, wenn kein einziger
|
||||
Schlüssel drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
|
||||
|
||||
**Kennt MakeMKV die Pressung selbst nicht**, bleibt die **`KEYDB.cfg`** als
|
||||
Notnagel (gleicher Tab). Und: **AACS-Dumps herunterladen** — den Dump legt
|
||||
MakeMKV bei jeder unbekannten Disc selbst ab, und genau den braucht man, wenn
|
||||
man im MakeMKV-Forum um den Schlüssel für eine neue Pressung bittet.
|
||||
|
||||
> ⚠️ **Rippy liefert keine Disc-Schlüssel mit, lädt keine herunter und
|
||||
> verteilt keine.** Rippy hält nur den Platz für eine Datei bereit, die du
|
||||
> selbst mitbringst, und zeigt ehrlich an, was dort liegt. Der
|
||||
> MakeMKV-**Beta-Key** ist die Lizenz für die *Software* — zwei völlig
|
||||
> verschiedene Dinge.
|
||||
|
||||
⚠️ UHD-Rohdaten sind bis 100 GB groß. Ist die Platte zu klein, lege das
|
||||
**Arbeitsverzeichnis** (Einstellungen → Verarbeitung) auf eine eingehängte
|
||||
Freigabe. Rippy bricht sonst **vor** dem Rip mit Klartext ab statt nach 40 GB
|
||||
mit voller Platte.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Maschinen als Worker
|
||||
|
||||
Die Kompression läuft als eigener Task auf der Queue `transcode` — **jede**
|
||||
Maschine im Netz kann sie übernehmen. Einstellungen → Worker zeigt für beide
|
||||
Varianten einen Copy-Paste-Befehl:
|
||||
|
||||
- **Windows (nativ, ohne Docker)** — braucht nur Python. Der Installer kommt
|
||||
als fertige `.exe` von Rippy selbst; Worker-Code und HandBrake holt er zur
|
||||
Laufzeit.
|
||||
- **Linux (Docker)** — `deploy/remote-transcode-worker.yml`.
|
||||
|
||||
Rippy zeigt für jeden Worker an, **was er wirklich kann**: Encoder, Kernzahl,
|
||||
Vektorbefehle. Für einen **GPU-Worker** wichtig: das Bild braucht ein
|
||||
HandBrake mit `nvenc_*` bzw. `qsv_*` — die Anzeige nennt HandBrakes
|
||||
ungefilterte Auskunft, damit du das nachprüfen kannst. Das mitgelieferte
|
||||
Linux-Worker-Bild hat **keinen** Hardware-Encoder.
|
||||
|
||||
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy bleibt
|
||||
All-in-one.
|
||||
|
||||
---
|
||||
|
||||
## Für Fortgeschrittene
|
||||
|
||||
<details>
|
||||
<summary><b>Umgebungsvariablen (.env)</b></summary>
|
||||
|
||||
`install.sh` füllt die hostabhängigen Werte selbst. Alles hier ist optional —
|
||||
UI-Einstellungen überstimmen die Env-Variablen.
|
||||
|
||||
| Variable | Zweck |
|
||||
|---|---|
|
||||
| `TMDB_API_KEY` | Metadaten (deutsche Texte). Bequemer im Assistenten — beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
||||
| `OMDB_API_KEY` | zweite Metadaten-Quelle (Fallback) |
|
||||
| `THETVDB_API_KEY` | Serien-Fallback |
|
||||
| `WORKER_NAME` | Anzeigename des eingebauten Workers (Standard `rippy-hauptworker`) |
|
||||
| `MAKEMKV_APP_KEY` | MakeMKV-Beta-Key. Bequemer im UI unter Einstellungen → System — gilt ab dem nächsten Rip, ohne Rebuild |
|
||||
| `MAKEMKV_VERSION` | MakeMKV-Version für den Image-Build |
|
||||
| `MAKEMKV_URL_BASE` | Download-Quelle für den Build. Normalerweise **nichts eintragen** — der Standard `makemkv.com/download` liefert (am 25.07.2026 mit HTTP 200 geprüft; `/download/old` gibt 525, ein web.archive.org-Schnappschuss 404) |
|
||||
| `MAKEMKV_URL_FALLBACK` | zweite Quelle, die bei Fehlschlag der ersten **automatisch** versucht wird. Leer = keine. Trage nur ein, was du selbst geprüft hast: eine Adresse, die nicht liefert, lässt die Konfiguration gesund aussehen und den Build später scheitern |
|
||||
| `MAKEMKV_DATA_HOST` | Host-Verzeichnis für MakeMKVs Daten (Standard `/srv/rippy/makemkv`) — Schlüsselspeicher, `KEYDB.cfg`, AACS-Dumps. Persistent, überlebt jeden Rebuild |
|
||||
| `OPTICAL_SR` / `OPTICAL_SG` | Geräteknoten des Laufwerks. **Findet `install.sh` selbst** |
|
||||
| `POSTGRES_PASSWORD` | DB-Passwort (Standard `rippy`) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Installation ohne install.sh (von Hand)</b></summary>
|
||||
|
||||
Falls du jeden Schritt selbst machen willst:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
mkdir -p /srv/rippy/media /srv/rippy/makemkv
|
||||
|
||||
# Laufwerksknoten ermitteln — die sg-Nummer ist je Host anders!
|
||||
lsscsi -g # zeigt Modell + zugehoerigen /dev/sgN
|
||||
# in die .env: OPTICAL_SR=/dev/sr0 und OPTICAL_SG=/dev/sgN
|
||||
|
||||
# Nur falls 'docker compose up' meldet:
|
||||
# "path ... is mounted on / but it is not a shared mount"
|
||||
mount --bind /srv/rippy/media /srv/rippy/media
|
||||
mount --make-rshared /srv/rippy/media # neustart-fest als systemd-.mount-Unit
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Braucht klassisches Linux-Docker mit `SYS_ADMIN` — nicht Docker Desktop,
|
||||
rootless oder Podman. Die Mount-Propagation ist nur für das Einhängen von
|
||||
NAS-Freigaben **aus dem UI** nötig; Freigaben auf dem Host einhängen geht immer.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Wie es intern funktioniert</b></summary>
|
||||
|
||||
1. **Disc-Wache** — ioctl-Polling, kein udev. Im Container läuft kein udevd,
|
||||
die udev-Datenbank ist leer; `udevadm info` liefert dort prinzipbedingt
|
||||
nichts.
|
||||
2. **Rip** — MakeMKV, verlustfrei. Der einzige Weg durch AACS: HandBrake kann
|
||||
verschlüsselte Discs nicht lesen, deshalb sind es zwingend zwei Stufen.
|
||||
3. **Kompression** — HandBrake als eigener Celery-Task auf eigener Queue,
|
||||
deshalb an andere Maschinen routbar.
|
||||
4. **Metadaten** — Volume-Label → TMDB → OMDb → MyAnimeList, mit
|
||||
Ähnlichkeits-Bewertung und Korrektur-Popup an der Disc-Karte.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Härtung für exponierte Netze</b></summary>
|
||||
|
||||
Rippy ist bewusst **Heimnetz-only**: keine Authentifizierung, und Redis +
|
||||
PostgreSQL veröffentlichen Ports für Remote-Worker. Im vertrauten LAN ein
|
||||
akzeptierter Kompromiss — **exponiere Rippy niemals ungeschützt an ein
|
||||
öffentliches Netz.**
|
||||
|
||||
- **Zugriff kapseln:** UI und API (`:8000`) hinter einen Reverse-Proxy mit Auth
|
||||
oder nur über VPN erreichbar machen.
|
||||
- **DB/Broker abschotten:** starkes `POSTGRES_PASSWORD`; die Ports
|
||||
`5432`/`6379` nur an ein internes/VPN-Interface binden statt an `0.0.0.0`;
|
||||
Redis mit `--requirepass` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||
- **CORS:** `main.py` erlaubt `*` (nötig, weil UI und API getrennte Ports
|
||||
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||
- **NAS-Zugangsdaten** liegen im Klartext in der DB — ein weiterer Grund, den
|
||||
DB-Port nie offen ins unsichere Netz zu hängen.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Updates der Werkzeuge</b></summary>
|
||||
|
||||
„Auf Updates prüfen" (Einstellungen → System) vergleicht mit makemkv.com und
|
||||
den offiziellen HandBrake-Releases, 12 h gecacht.
|
||||
|
||||
**MakeMKV** aktualisieren: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
|
||||
`sudo ./install.sh`. Der Beta-Key wechselt etwa monatlich (Forum-Thread
|
||||
t=1053) und wird im UI gepflegt — ohne Rebuild.
|
||||
|
||||
**HandBrake** kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
|
||||
offiziellen Version hinterher); der native Windows-Worker nutzt die aktuelle.
|
||||
|
||||
⚠️ Ein MakeMKV-Update hilft **nicht** gegen „The volume key is unknown" —
|
||||
MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es holt sie zur Laufzeit,
|
||||
und die Linux-Version holt sie nie. Siehe [4K-UHD](#4k-uhd).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||
einen Branch, `main` — die Ampel prüft nur, deployt wird direkt aus `main`.
|
||||
Rot heißt: nicht deployen.
|
||||
|
||||
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
||||
Fahrplan: [ROADMAP.md](ROADMAP.md) · Stand: [SAVEPOINT.md](SAVEPOINT.md)
|
||||
|
||||
+336
-1
@@ -249,5 +249,340 @@ Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf.
|
||||
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
||||
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 12: Besser als ARM (aus ARM-Analyse 23.07.2026)
|
||||
|
||||
**Quelle:** Vollanalyse von Automatic Ripping Machine v2.24 (Code, arm.yaml,
|
||||
Wiki, Issues). Was Rippy strukturell schon besser macht: Lossless-first statt
|
||||
Transcode-first (ARMs BD-Default ist ein 1080p30-Downscale!), Celery+Postgres
|
||||
statt Prozess-pro-udev-Event+SQLite (ARM-UI friert beim Rippen ein, #1046),
|
||||
ioctl-Watcher statt fragiler udev-Ketten (ARMs größte Support-Quelle), NFO +
|
||||
Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs).
|
||||
|
||||
**Übernehmen (priorisiert):**
|
||||
- [ ] **Disc-Fingerprint-DB (selbstlernend)** — ARMs beste Idee, besser gemacht:
|
||||
Tabelle disc_fingerprints (DVD-CRC64/MusicBrainz-DiscID/BD-Label-Hash →
|
||||
bestätigte Metadaten). Jede UI-Bestätigung schreibt zurück; zweite Disc
|
||||
derselben Serie wird sofort erkannt. Lokal statt ARMs Crowd-Server
|
||||
(Single Point of Failure auf Free-Hosting).
|
||||
- [ ] **BD-Titel aus BDMV/META/DL/bdmt_eng.xml** als Identifikations-Stufe vor
|
||||
den APIs (echter Disc-Titel statt Label-Raterei).
|
||||
- [ ] **Progressive Suchdegradation** bei TMDB/OMDb: mit Jahr → ohne Jahr →
|
||||
tokenweises Titel-Stripping; jede Stufe senkt die Confidence.
|
||||
- [ ] **Duplikat-Schutz**: Fingerprint-Treffer → „schon gerippt am X — trotzdem?"
|
||||
- [ ] **Track-Auswahl (Manual Mode), besser als ARM**: Job-Status
|
||||
awaiting_selection nach makemkvcon-info; Track-Tabelle im UI mit
|
||||
Heuristik-Vorauswahl; Timeout → Weiter mit Default (ARM bricht hart ab,
|
||||
erlaubt nur EINE Auswahl).
|
||||
- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht
|
||||
Jellyfin nativ) statt alles flach.
|
||||
- [x] **Benachrichtigungen bei Job-Ende** — 24.07., eigenes notify-Modul
|
||||
(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,
|
||||
UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung.
|
||||
- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs.
|
||||
- [ ] **Jellyfin-Refresh** nach Ablage (POST /Library/Refresh).
|
||||
|
||||
**ARM schlagen (Alleinstellung):**
|
||||
- [ ] **Serien-Episoden-Erkennung** — ARMs offene Wunde seit 2019 (#395):
|
||||
TVDB-Episodenlaufzeiten gegen Track-Laufzeiten matchen (±5 %,
|
||||
Reihenfolge-Constraint, Disc-Nr aus Label), Vorschlag
|
||||
„Show/Season 02/Show S02E05.mkv" im Preview bestätigen.
|
||||
- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt
|
||||
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).
|
||||
- [x] **Download-Knopf für fertige Rips** (Wunsch aus der Übernahme-Session):
|
||||
GET /jobs/{id}/files (+ Datei-Stream, Pfad-Validierung strikt unter
|
||||
/app/media inkl. realpath-Check), Download-Knopf in der Aktion-Spalte,
|
||||
Dateiliste mit Größen im Job-Detail-Popup — vorher kam man an fertige
|
||||
MKVs nur per scp. nginx hatte proxy_buffering off schon (SSE).
|
||||
|
||||
**Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 14 (24.07.2026): Praxis-Feedback-Runde — erste echte Nutzung
|
||||
|
||||
**Quelle:** Commander-Feedback nach dem ersten richtigen Arbeiten mit v3.2.
|
||||
|
||||
- [x] SMB-Mount-Blocker: CAP_DAC_READ_SEARCH für mount.cifs (auf der VM
|
||||
reproduziert + bewiesen), Compose-Fix.
|
||||
- [x] TMDB-Key-Falle: v3-Schlüssel wurden still 401 (Client konnte nur
|
||||
v4-Bearer) — beide Arten unterstützt, „Verbindung prüfen" im UI.
|
||||
- [x] 4K UHD als eigener Disc-Typ (≥ 55 GiB) mit eigener Farbe + Klartext-
|
||||
Fehler bei Nicht-LibreDrive-Laufwerken.
|
||||
- [x] Vollautomatik-Setting (Disc rein → Rip startet ohne Popup).
|
||||
- [x] Job-Verwaltung: can_retry (Knopf nur bei vorhandenen Rohdaten),
|
||||
Einzel-Löschen, „Erledigte aufräumen", „Alle herunterladen".
|
||||
- [x] Worker: WORKER_NAME-Anzeigename + IP/ID, verwaiste Einträge löschbar.
|
||||
- [x] Ripping-Tab nach Medium (Video/Audio/Allgemein), Untertitel-Klartext,
|
||||
CD → Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung eingeklappt.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 15 (24.07.2026): Restefeger — der Ideen-Katalog wird abgearbeitet
|
||||
|
||||
**Commander-Auftrag:** „Arbeite alles ab, abgesehen von Auth — das ist
|
||||
irrelevant und kann komplett raus."
|
||||
|
||||
- [x] **AUTH KOMPLETT ENTFERNT** (Commander-Entscheid): /token- und
|
||||
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT,
|
||||
JWT_SECRET_KEY-Pflicht — alles raus (KONZEPT §10). Der Schnellstart
|
||||
braucht keine .env-Pflichtwerte mehr. Rate-Limit pro IP bleibt.
|
||||
- [x] **Serien-Staffel-Flow**: Im Rip-Dialog Serienname + Staffel →
|
||||
Ablage `<Serie>/Season NN`; tvshow.nfo + poster.jpg landen im
|
||||
Serien-Ordner (werden bei Staffel 2 nicht überschrieben).
|
||||
- [x] **Episoden-Erkennung per Laufzeitabgleich** (ARM-Wunde #395):
|
||||
Datei-Laufzeiten (HandBrake-Scan) gegen TMDB-Episoden-Laufzeiten,
|
||||
ordnungserhaltend; komplette Staffel auf einer Disc geht auch bei
|
||||
uniformen Laufzeiten. Umbenannt wird NUR bei eindeutiger Zuordnung
|
||||
(„Serie S01E02.mkv"), sonst bleiben die Namen + ehrliches Log.
|
||||
- [x] **Jellyfin/Emby-Bibliotheks-Refresh** nach jedem fertigen Rip
|
||||
(POST /Library/Refresh, X-Emby-Token) — URL/Key + Test-Knopf in den
|
||||
Einstellungen. Die Null-Klick-Kette Disc→Bildschirm steht.
|
||||
- [x] **Duplikat-Warnung**: Disc-Fingerabdruck gegen die Job-Historie —
|
||||
Hinweis auf der Disc-Karte, Vollautomatik überspringt Duplikate.
|
||||
- [x] **„Nur Hauptfilm" ECHT**: das Setting war wirkungslos — jetzt
|
||||
Info-Lauf → längster Titel → nur der wird gerippt; pro Rip im
|
||||
Dialog übersteuerbar. (Volle Track-Tabelle bleibt Ausbaustufe.)
|
||||
- [x] **Deutsche Texte für OMDb-Treffer** via TMDB /find (IMDb-ID → de-DE).
|
||||
- [x] **Speicherplatz in der Dashboard-Leiste** (amber unter 60 GB frei).
|
||||
- [x] **CSV-Export** der Job-Historie (Semikolon+BOM, Excel-tauglich).
|
||||
- [x] **Metadaten-Seite entfernt** (+ Placebo-Endpoints /metadata/lookup
|
||||
und /metadata/confirm — lookup scannte ein Dummy-Device).
|
||||
- [x] **Doppel-Jahr-Fix** („X (2009) (2009)" in Log/Ordnernamen).
|
||||
- [x] **Remote-Worker-Blocker**: redis/postgres waren nie veröffentlicht —
|
||||
kein Remote-Worker konnte sich je verbinden. Ports 6379/5432 jetzt
|
||||
offen (Heimnetz-Kompromiss, dokumentiert) + API_URL für Worker.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 16 (24.07.2026): Track-Tabelle + nativer Windows-Worker + Anleitung
|
||||
|
||||
- [x] **Volle Titel-Auswahl vor dem Rip**: scan_tracks-Worker-Task,
|
||||
Polling-Endpoints, Tabelle (Dauer/Größe/Kapitel, Vorauswahl ≥ 5 min)
|
||||
im Rip-Dialog, Multi-Titel-Rip (ein makemkvcon-Aufruf je Titel,
|
||||
Fortschritt anteilig). Parser mit Tests.
|
||||
- [x] **Nativer Windows-Worker ohne Docker**: install.ps1 + Worker-Code
|
||||
werden von der Rippy-Instanz selbst serviert (/worker-setup/*);
|
||||
HandBrakeCLI 1.9.2 vom offiziellen GitHub-Release; celery
|
||||
--pool=solo -Q transcode; optional Autostart. UI bietet Linux- und
|
||||
Windows-Variante. E2E auf echtem Windows-PC bewiesen.
|
||||
- [x] **Anleitungs-Tab**: Rippy erklärt sich selbst (Disc-Weg, Serien,
|
||||
NAS, Media-Server, Benachrichtigungen, Key, Worker, FAQ).
|
||||
|
||||
---
|
||||
|
||||
## Etappe 17 (25.07.2026): 4K-UHD-Schlüssel (KEYDB.cfg)
|
||||
|
||||
**Quelle:** Messung am 25.07. live auf der Rippy-VM im Worker-Container.
|
||||
Eine 4K-UHD (Akira, MKB v76, Pressung Dezember 2020) scheitert an „The
|
||||
volume key is unknown for this disc" — obwohl makemkvcon „Using LibreDrive
|
||||
mode (v06.3)" und „Using direct disc access mode" meldet, die Disc liest
|
||||
und den AACS-Dump ablegt (Meldung 3332). Das Debug-Log geht ohne einen
|
||||
einzigen Netz-Versuch von „Loaded content hash table" direkt auf den
|
||||
Fehler; `_private_data.tar` enthielt nur die Index-Datei und keine einzige
|
||||
`hkd_*.bin`; auch mit gelöschter `update.conf` (Meldung 5074 belegt den
|
||||
Web-Kontakt) und `app_UpdateEnable = "1"` kam kein Schlüssel; die
|
||||
Forum-Schlüssel-Server `hkdata.fairuse.org` und `hkdata.crabdance.com`
|
||||
lösen weltweit nicht mehr auf. **Fazit: Ein MakeMKV-Update löst das
|
||||
nicht** (das behauptete v3.3 — dort richtiggestellt). Der einzige heute
|
||||
funktionierende Weg ist eine `KEYDB.cfg` im MakeMKV-Datenverzeichnis.
|
||||
|
||||
> ⚠️ **Nachtrag am selben Tag (siehe Etappe 18): Die letzte Folgerung war
|
||||
> falsch.** Die beiden toten Hostnamen stammen aus alten Forumsbeiträgen und
|
||||
> werden von MakeMKV längst nicht mehr benutzt — der Dienst lebt. Richtig
|
||||
> ist: `makemkvcon` unter **Linux** fragt nie nach Schlüsseln, die
|
||||
> **Windows**-Version schon. Alles hier Gebaute bleibt richtig und nötig,
|
||||
> nur die `KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
|
||||
|
||||
**Gebaut:**
|
||||
- [x] **Persistentes Datenverzeichnis**: `${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}`
|
||||
vom Host gemountet — Worker `/root/.MakeMKV`, API `/app/makemkv-data`,
|
||||
beide mit `MAKEMKV_DATA_DIR`. `KEYDB.cfg` und AACS-Dumps überleben
|
||||
jeden Rebuild. `.env.example` erklärt die Variable.
|
||||
- [x] **entrypoint.sh entschärft**: `settings.conf` wird ergänzt statt
|
||||
überschrieben (der Beta-Key hatte sonst alles andere gelöscht),
|
||||
`app_UpdateEnable = "1"` gesetzt.
|
||||
- [x] **Zwillings-Modul `makemkv_daten.py`** (identisch in `docker/api/`
|
||||
und `docker/worker/`) als einzige Wahrheit über das Verzeichnis:
|
||||
Status lesen, Inhalt prüfen, atomar schreiben, löschen, Dumps
|
||||
auflisten — alles reine Funktionen, damit die Ampel sie ohne
|
||||
Postgres/Redis testen kann.
|
||||
- [x] **API**: `GET/POST/DELETE /system/keydb`, `GET /system/aacs-dumps`
|
||||
und `GET /system/aacs-dumps/{dateiname}`. Der Inhalt kommt bewusst
|
||||
als JSON-Body — es gibt kein `python-multipart`, ein Endpunkt mit
|
||||
`UploadFile`/`File()` würde die API beim Import töten.
|
||||
- [x] **UI (Einstellungen → System)**: Status der `KEYDB.cfg` (Pfad,
|
||||
Größe, Anzahl Disc-Einträge, Datum), Inhalt einfügen, entfernen,
|
||||
AACS-Dumps herunterladen — ohne SSH auf die VM.
|
||||
- [x] **MakeMKV redet endlich**: `parse_msg()` in `ripping.py` + Log-Callback
|
||||
in `tasks.py` schreiben MakeMKV-Meldungen ins Rippy-Log (gedrosselt:
|
||||
Code 1003 raus, keine Wiederholungen, max. 40 je Rip). UHD-Fehlertext
|
||||
ehrlich neu geschrieben; `caps.py` meldet `keydb: ja | nein | unbekannt`.
|
||||
- [x] **Doku nachgezogen**: README (UHD-Voraussetzungen, System-Panel,
|
||||
`MAKEMKV_DATA_HOST`, Bereitstellung), SAVEPOINT v3.10 inkl.
|
||||
Richtigstellung von v3.3, KONZEPT §8 + §10.
|
||||
|
||||
**Offen aus dieser Runde:**
|
||||
- [ ] **Der Nachweis mit einer echten `KEYDB.cfg` steht aus.** Zum
|
||||
Zeitpunkt der Änderung lag keine Datei vor, die den Akira-Schlüssel
|
||||
enthält — belegt sind der Befund und die Mechanik, NICHT ein
|
||||
erfolgreicher UHD-Rip.
|
||||
- [ ] Damit bleibt auch der volle Transcode-E2E an einer UHD weiter offen
|
||||
(siehe SAVEPOINT v3.7) — an normalen BD/DVD ist die Kette bewiesen.
|
||||
|
||||
**Nachtrag zu „Offen":** Beide Punkte sind mit Etappe 18 erledigt — Akira
|
||||
ging auf der VM auf, allerdings über den Schlüsselspeicher statt über eine
|
||||
`KEYDB.cfg`.
|
||||
|
||||
**Ausdrücklich nicht gebaut (und wird es auch nicht):** Rippy liefert
|
||||
keine Disc-Schlüssel mit, lädt keine herunter und verteilt keine. Es
|
||||
stellt nur den Platz für eine Datei bereit, die der Nutzer selbst
|
||||
mitbringt, und zeigt ehrlich an, was dort liegt.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 18 (25.07.2026): 4K-UHD gelöst — Schlüsselspeicher statt KEYDB.cfg
|
||||
|
||||
**Quelle:** Einwand des Commanders („wenn ich MakeMKV lokal auf meinem
|
||||
Windows-PC installiere, würde es SOFORT gehen — wir übersehen etwas
|
||||
Gewaltiges"). Er hatte recht. Gegenprobe mit demselben Laufwerk und
|
||||
derselben Disc an einem Windows-PC:
|
||||
|
||||
| | Linux (Worker) | Windows |
|
||||
|---|---|---|
|
||||
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
|
||||
| Meldung 3338 „Downloading latest HK" | nie | ja |
|
||||
| `_private_data.tar` | 2048 B, 0 Schlüssel | 6,4 MB, 604 Schlüssel |
|
||||
| Disc | „volume key is unknown" | **TCOUNT:5, geht auf** |
|
||||
|
||||
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
|
||||
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`: Linux
|
||||
fragt nie. Die Meldungsvorlage „Downloading latest %1 to %2 ..." steckt
|
||||
sehr wohl im Linux-Binary — sie löst nur nicht aus. Gleiches Symptom im
|
||||
MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
|
||||
**Damit ist die Diagnose aus Etappe 17 („der Schlüssel-Kanal ist tot")
|
||||
widerlegt.** Sie stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen,
|
||||
die MakeMKV längst nicht mehr benutzt.
|
||||
|
||||
**Gebaut:**
|
||||
- [x] **Schlüsselspeicher im Modul**: `zaehle_schluessel`,
|
||||
`private_data_pruefen`, `schluesselspeicher_status`,
|
||||
`private_data_schreiben` in `makemkv_daten.py` (beide Zwillinge).
|
||||
Die Prüfung lehnt einen Speicher OHNE `hkd_*.bin` ab — sonst lädt
|
||||
jemand den leeren Vorrat einer frischen Installation hoch, es ändert
|
||||
sich nichts, und niemand versteht warum.
|
||||
- [x] **API**: `GET /system/keystore` und `POST /system/keystore`. Der
|
||||
Rohkörper der Anfrage IST die Datei — binär, deshalb kein JSON und
|
||||
kein Base64; Multipart kann die API ohnehin nicht.
|
||||
- [x] **UI**: neuer Block „Disc-Schlüssel für 4K-UHD" ÜBER dem
|
||||
KEYDB-Block, mit Schlüssel-Anzahl, Upload und Schritt-für-Schritt-
|
||||
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als Notnagel
|
||||
beschriftet. Worker-Plakette zeigt die Schlüssel-Anzahl.
|
||||
- [x] **Fehlertext** in `tasks.py` sagt den Windows-Weg an und nennt die
|
||||
Anzahl bekannter Schlüssel dieses Workers.
|
||||
- [x] **`caps.py`** meldet `schluessel` je Worker.
|
||||
- [x] **Alle Falschaussagen korrigiert**: UI (3 Stellen), Anleitung (2),
|
||||
README (3), KONZEPT §8 + §10, `makemkv_daten.py`-Modulkopf,
|
||||
Worker-Dockerfile, `makemkv_key.py`.
|
||||
|
||||
**Bewiesen:** Nach Übernahme des Windows-Schlüsselspeichers öffnet
|
||||
`makemkvcon` auf der VM die Akira-UHD — „Operation successfully
|
||||
completed", `TCOUNT:5`, fünf Titel, identisch zum Windows-Ergebnis.
|
||||
**Das ist der erste belegte UHD-Disc-Zugriff auf der Rippy-Maschine.**
|
||||
|
||||
**Offen aus dieser Runde:**
|
||||
- [ ] Voller UHD-Rip inklusive Transcode-E2E (nur der Disc-Zugriff ist
|
||||
belegt, nicht die komplette Kette bis zur fertigen Datei).
|
||||
- [ ] Ob sich der Abruf unter Linux doch anstoßen lässt, ist ungeklärt —
|
||||
der Code ist im Binary vorhanden. Bis dahin bleibt der Windows-Umweg.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 19 (25.07.2026): Preset je Disc-Typ
|
||||
|
||||
**Quelle:** Rückfrage des Commanders beim ersten echten UHD-Rip („merkt
|
||||
Rippy eigentlich, wenn es eine UHD-Disc ist, und wendet direkt das
|
||||
4K-Preset an?"). Antwort war: nein. Die Kompression fragte den Disc-Typ
|
||||
gar nicht — ein globales `transcodePreset` galt für alles, live eingestellt
|
||||
`HQ 1080p30 Surround`. Der laufende 4K-Rip wäre danach auf 1080p
|
||||
heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
|
||||
|
||||
**Gebaut:**
|
||||
- [x] **`preset_fuer(disc_type, einstellungen)`** in `ripping.py` — pure
|
||||
Funktion, drei Tests. Reihenfolge: Preset des Disc-Typs → allgemeines
|
||||
`transcodePreset` → `DEFAULT_HB_PRESET`. Bestandsinstallationen
|
||||
ändern ihr Verhalten NICHT, solange die neuen Felder ungespeichert sind.
|
||||
- [x] **`transcode_files`** holt den Disc-Typ aus dem Job-Datensatz und
|
||||
schreibt ihn mit ins Log („Disc-Typ 'uhd', Preset '…'").
|
||||
- [x] **UI**: drei Auswahlfelder (DVD / Blu-ray / 4K-UHD) statt einem, mit
|
||||
Erklärung, warum 4K auf ein 2160p-Preset gehört. Preset-Namen aus
|
||||
`HandBrakeCLI --preset-list` im Worker-Image belegt, nicht geraten.
|
||||
- [x] **Sofortmaßnahme am laufenden Job**: `keepOriginal` auf `True`, damit
|
||||
der 4K-Rohschnitt die Kompression überlebt.
|
||||
|
||||
**Offen aus dieser Runde:**
|
||||
- [ ] **Deploy steht aus** — er würde den laufenden Akira-Rip abbrechen.
|
||||
Erst nach Abschluss des Jobs deployen, dann bei Bedarf „Neu
|
||||
komprimieren" mit dem 4K-Preset.
|
||||
|
||||
---
|
||||
|
||||
## Ideen-Katalog (Rest) — bewusst offen
|
||||
|
||||
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
||||
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
|
||||
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
|
||||
`theme === 'dark'`-Ternaries → Tailwind-`dark:` + UI-Primitives,
|
||||
Optik-Modernisierung, ohne Funktions-/Text-Änderungen. Der neue
|
||||
Anleitungs-Tab gehört mit in den Feinschliff.
|
||||
2. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
|
||||
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
|
||||
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
|
||||
3. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
|
||||
Jellyfin/Emby.
|
||||
4. **Windows-Worker als echter Dienst** (statt start-worker.bat/onlogon):
|
||||
z. B. via NSSM oder PyInstaller-Paket — Komfort-Ausbau der jetzt
|
||||
funktionierenden nativen Variante.
|
||||
|
||||
+2166
-1
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# NOTFALL-Deploy für Rippy (Normalweg = Grün-Gate: push → Ampel grün → CI
|
||||
# befördert auf 'stable' → Arcane-GitSync deployt).
|
||||
# Dieses Skript nur nutzen, wenn der Automat klemmt (Arcane down o. ä.).
|
||||
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
|
||||
# zu deployenden Commit GRUEN ist.
|
||||
#
|
||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy, Branch stable) und fasst Arcanes
|
||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
||||
# im Arcane-Verzeichnis kollidiert mit dessen Promote (read-only .git-Objekte).
|
||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
||||
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||
# reicht ohnehin der einfache Weg (siehe README):
|
||||
# git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
|
||||
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
|
||||
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
|
||||
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
|
||||
#
|
||||
# Konfiguration ueber Umgebungsvariablen:
|
||||
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
|
||||
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
|
||||
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
|
||||
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
|
||||
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
set -euo pipefail
|
||||
|
||||
VM="arcane@192.168.178.162"
|
||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||
REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
|
||||
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
|
||||
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
|
||||
DIENST="${1:-}"
|
||||
|
||||
ssh "$VM" 'bash -s' <<REMOTE
|
||||
ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
||||
git clone --branch stable https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
||||
REPO_URL="$1"
|
||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||
CLONE="${3:-$HOME/notfall-rippy}"
|
||||
# ⚠️ ${4:-}, nicht "$4" (Befund 26.07.2026): Leere Argumente ueberleben den Weg
|
||||
# durch ssh nicht — die Gegenseite bekommt die Befehlszeile als EINEN String und
|
||||
# parst sie neu, wobei "" ersatzlos verschwindet. Wer also ohne RIPPY_ENV_SRC,
|
||||
# RIPPY_CLONE und ohne Dienst-Argument deployte (der dokumentierte Normalfall!),
|
||||
# bekam von 'set -u' nur ein "line 5: $4: unbound variable" und kein Deploy.
|
||||
DIENST="${4:-}"
|
||||
|
||||
# ⚠️ NICHT DEPLOYEN, WÄHREND EIN JOB LÄUFT (Vorfall 26.07.2026, selbst verursacht)
|
||||
#
|
||||
# `docker compose up -d --build` baut den worker-Container neu — und tötet damit
|
||||
# einen laufenden Rip. Genau das ist passiert: mitten in einem Blu-ray-Rip, bei
|
||||
# 12 %, nach 5,1 GB. Die Warnung stand im SAVEPOINT und half nichts, weil sie
|
||||
# niemand las und nichts sie prüfte. Jetzt prüft es das Skript.
|
||||
#
|
||||
# Übersteuern mit RIPPY_TROTZDEM=1 — dann ist es eine Entscheidung und kein
|
||||
# Versehen.
|
||||
if [ -z "${RIPPY_TROTZDEM:-}" ]; then
|
||||
ANTWORT="$(curl -s -m 5 http://localhost:8000/health/arbeit 2>/dev/null || true)"
|
||||
case "$ANTWORT" in
|
||||
*'"arbeit":true'*|*'"arbeit": true'*)
|
||||
echo "ABBRUCH: Auf dieser Maschine läuft gerade ein Job." >&2
|
||||
echo " Ein Rebuild des worker-Containers würde ihn töten." >&2
|
||||
echo "$ANTWORT" | tr ',' '\n' | grep -E '"(status|titel|progress)"' >&2 || true
|
||||
echo " Warten, oder bewusst überstimmen: RIPPY_TROTZDEM=1 ./deploy.sh" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
cd ~/notfall-rippy
|
||||
git fetch origin stable
|
||||
git reset --hard origin/stable
|
||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
||||
|
||||
if [ ! -d "$CLONE/.git" ]; then
|
||||
git clone --branch main "$REPO_URL" "$CLONE"
|
||||
fi
|
||||
cd "$CLONE"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
# ⚠️ Eine fehlende .env-Quelle war bisher eine geschluckte Warnung — und damit
|
||||
# eine Falle (Befund 26.07.2026). Auf der Ziel-VM lag die echte .env unter
|
||||
# ~/projects/rippy/.env, der Default zeigte auf ~/rippy/.env. Das `cp` schlug
|
||||
# also jedes Mal fehl, die Zeile scrollte im Build-Ausgabe-Rauschen vorbei, und
|
||||
# gebaut wurde mit der Kopie im Klon: zwei Tage alt, mit einem inzwischen toten
|
||||
# MAKEMKV_URL_BASE-Notbehelf und einem JWT_SECRET_KEY aus der Zeit vor dem
|
||||
# Auth-Rueckbau. Ergebnis: jeder worker-Build brach ab, und die Ursache stand
|
||||
# nirgends.
|
||||
if [ -f "$ENV_SRC" ]; then
|
||||
cp "$ENV_SRC" .env
|
||||
echo "OK: .env aus $ENV_SRC uebernommen."
|
||||
elif [ -f .env ]; then
|
||||
echo "WARNUNG: $ENV_SRC gibt es nicht — es gilt die .env IM KLON ($CLONE/.env)." >&2
|
||||
echo " Stand dieser Datei: $(date -r .env '+%d.%m.%Y %H:%M')" >&2
|
||||
echo " Ist das nicht gewollt: RIPPY_ENV_SRC=<pfad> setzen." >&2
|
||||
ls -1 "$HOME"/*/.env "$HOME"/*/*/.env 2>/dev/null | head -5 | sed 's/^/ Kandidat: /' >&2 || true
|
||||
else
|
||||
echo "ABBRUCH: Keine .env — weder unter $ENV_SRC noch im Klon." >&2
|
||||
echo " Ohne sie baut compose mit leeren Werten (kein DB-Passwort," >&2
|
||||
echo " keine API-Schluessel). RIPPY_ENV_SRC=<pfad> setzen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -i '/^RIPPY_VERSION=/d' .env
|
||||
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
|
||||
|
||||
docker compose -p rippy up -d --build $DIENST
|
||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz (Linux).
|
||||
# Für Windows gibt es den nativen Weg: RippyWorkerSetup.exe (Einstellungen → Worker).
|
||||
#
|
||||
# ══ DAS ENTSCHEIDENDE: der Worker muss die Dateien SEHEN ══════════════════════
|
||||
# Rippy schickt ihm Pfade wie /app/media/rippy/<job>/title_t00.mkv — Pfade
|
||||
# INNERHALB des Rippy-Containers. Dieser Container muss dieselben Daten unter
|
||||
# GENAU DEMSELBEN Pfad haben, sonst nimmt er die Aufgabe an und lehnt sie
|
||||
# Millisekunden später ab („Keine Roh-MKVs gefunden" — genau so passiert am
|
||||
# 26.07.2026, Job 95afdc89).
|
||||
#
|
||||
# Richtigstellung 26.07.2026: Hier stand vorher „mount -t nfs
|
||||
# <rippy-host>:/srv/rippy /mnt/rippy". Das geht NICHT — auf der Rippy-Maschine
|
||||
# läuft kein NFS- und kein Samba-Server, sie ist selbst nur Client der NAS.
|
||||
# Der Weg, der funktioniert: DIESELBE Freigabe einhängen, die auch Rippy nutzt.
|
||||
#
|
||||
# Auf der GPU-Maschine:
|
||||
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
||||
# 2. Die Freigabe einhängen, die Rippy als Arbeitsverzeichnis UND Ablage
|
||||
# benutzt (Einstellungen → Ripping). Heißt sie in Rippy „rippy", liegt
|
||||
# sie dort unter /app/media/rippy — also z. B.:
|
||||
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
|
||||
# 3. Unten RIPPY_HOST setzen, den Freigabe-Pfad im volumes-Block anpassen
|
||||
# und starten:
|
||||
# RIPPY_HOST=<LAN-IP-der-Rippy-Maschine> docker compose \
|
||||
# -f deploy/remote-transcode-worker.yml up -d --build
|
||||
#
|
||||
# Passt der Pfad im Container nicht (weil die Freigabe woanders hängt), gibt es
|
||||
# als Ausweg RIPPY_PATH_MAP — dieselbe Übersetzung, die der Windows-Worker
|
||||
# nutzt, Format: /app/media/rippy=/mnt/anderer-pfad (Paare per „;").
|
||||
# Den fertigen Wert nennt GET /worker-setup/pfad-map.
|
||||
#
|
||||
# Der Worker meldet seine Encoder-Fähigkeiten automatisch — er taucht danach
|
||||
# unter Einstellungen → Worker auf. Er bedient NUR die transcode-Queue;
|
||||
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||
|
||||
services:
|
||||
transcode-worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/worker/Dockerfile
|
||||
command: ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "transcode", "-n", "gpu-worker@%h"]
|
||||
environment:
|
||||
- REDIS_URL=redis://${RIPPY_HOST:?RIPPY_HOST setzen}:6379/0
|
||||
- DATABASE_URL=postgresql://rippy:rippy@${RIPPY_HOST}:5432/rippy
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- RAW_DIR=/app/temp/raw
|
||||
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
|
||||
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
|
||||
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||
- API_URL=http://${RIPPY_HOST}:8000
|
||||
# Nur nötig, wenn die Freigabe hier NICHT unter demselben Pfad liegt wie
|
||||
# in Rippy. Fertigen Wert holen: curl http://${RIPPY_HOST}/api/worker-setup/pfad-map
|
||||
- RIPPY_PATH_MAP=${RIPPY_PATH_MAP:-}
|
||||
volumes:
|
||||
# LINKS der Pfad auf DIESER Maschine, RECHTS der Pfad, den Rippy nennt.
|
||||
# Rechts muss stehen, was in Rippy als Speicherziel heißt: ein Ziel
|
||||
# namens „rippy" ist dort /app/media/rippy. Prüfen mit
|
||||
# curl http://<rippy-host>/api/worker-setup/pfad-map
|
||||
- /mnt/rippy-freigabe:/app/media/rippy
|
||||
devices:
|
||||
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
||||
- /dev/dri:/dev/dri
|
||||
restart: unless-stopped
|
||||
Binary file not shown.
@@ -0,0 +1,412 @@
|
||||
import flet as ft
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
|
||||
def main(page: ft.Page):
|
||||
page.title = "Rippy Encoding-Worker Installation"
|
||||
page.window_width = 800
|
||||
page.window_height = 650
|
||||
page.bgcolor = "#0f172a"
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.scroll = ft.ScrollMode.AUTO
|
||||
|
||||
# Colors
|
||||
c_panel = "#1e293b"
|
||||
c_text = "#e2e8f0"
|
||||
c_muted = "#94a3b8"
|
||||
c_accent = "#6366f1"
|
||||
c_success = "#10b981"
|
||||
|
||||
# State
|
||||
std_ziel = r"C:\Program Files\Rippy Worker"
|
||||
|
||||
# UI Fields
|
||||
txt_host = ft.TextField(
|
||||
label="LAN-IP der Rippy-Maschine (Docker-Host)",
|
||||
width=400,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_name = ft.TextField(
|
||||
label="Name dieses Workers (frei wählbar)",
|
||||
width=250,
|
||||
value=os.environ.get("COMPUTERNAME", "worker").lower(),
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_slots = ft.Dropdown(
|
||||
label="Kerne (Gleichzeitig)",
|
||||
width=150,
|
||||
options=[ft.dropdown.Option(str(i)) for i in range(1, 9)],
|
||||
value="2" if os.cpu_count() and os.cpu_count() >= 12 else "1",
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
|
||||
txt_pfad = ft.TextField(
|
||||
label="Installieren nach",
|
||||
value=std_ziel,
|
||||
expand=True,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
txt_map = ft.TextField(
|
||||
label="Netzwerk-Freigabe mit den Rohdaten",
|
||||
expand=True,
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
)
|
||||
|
||||
chk_auto = ft.Checkbox(
|
||||
label="Beim Anmelden automatisch starten (Hintergrund)",
|
||||
value=True,
|
||||
fill_color=c_accent,
|
||||
)
|
||||
chk_desktop = ft.Checkbox(
|
||||
label="Verknüpfung auf dem Desktop anlegen", value=True, fill_color=c_accent
|
||||
)
|
||||
|
||||
log_view = ft.ListView(height=150, auto_scroll=True, spacing=2)
|
||||
|
||||
def log(msg: str):
|
||||
log_view.controls.append(
|
||||
ft.Text(msg, color=c_muted, font_family="Consolas", size=12)
|
||||
)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_map(e):
|
||||
r_host = txt_host.value.strip()
|
||||
if not r_host:
|
||||
page.snack_bar = ft.SnackBar(
|
||||
ft.Text("Bitte zuerst die LAN-IP eintragen!"), bgcolor="red"
|
||||
)
|
||||
page.snack_bar.open = True
|
||||
page.update()
|
||||
return
|
||||
|
||||
try:
|
||||
log(f"Frage Rippy nach Freigabe ({r_host})...")
|
||||
with urllib.request.urlopen(
|
||||
f"http://{r_host}/api/worker-setup/pfad-map", timeout=5
|
||||
) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
if data.get("hinweis"):
|
||||
log(data["hinweis"])
|
||||
if data.get("mapping"):
|
||||
txt_map.value = data["mapping"]
|
||||
log(f"Erfolgreich geholt: {data['mapping']}")
|
||||
page.update()
|
||||
except Exception as ex:
|
||||
log(f"Fehler: {ex}")
|
||||
|
||||
def do_install(e):
|
||||
btn_install.disabled = True
|
||||
page.update()
|
||||
threading.Thread(target=run_installation, daemon=True).start()
|
||||
|
||||
def run_installation():
|
||||
r_host = txt_host.value.strip()
|
||||
w_name = txt_name.value.strip()
|
||||
install_dir = txt_pfad.value.strip()
|
||||
|
||||
if not r_host:
|
||||
log("FEHLER: Rippy LAN-IP fehlt!")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
log(f"Zielverzeichnis: {install_dir}")
|
||||
|
||||
# Check Python
|
||||
python_exe = None
|
||||
for cmd in ["py -3", "python"]:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
f"{cmd} --version", shell=True, capture_output=True, text=True
|
||||
)
|
||||
if "Python 3." in res.stdout:
|
||||
python_exe = cmd
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not python_exe:
|
||||
log("FEHLER: Python 3.10+ nicht gefunden.")
|
||||
log("Bitte mit 'winget install Python.Python.3.12' installieren.")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
log(f"Python gefunden: {python_exe}")
|
||||
|
||||
try:
|
||||
os.makedirs(install_dir, exist_ok=True)
|
||||
test_file = os.path.join(install_dir, ".test")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("x")
|
||||
os.remove(test_file)
|
||||
except Exception:
|
||||
log(f"FEHLER: Keine Schreibrechte in {install_dir}")
|
||||
log("Bitte als Administrator starten oder Pfad aendern.")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
# Download worker package
|
||||
log(f"Lade Worker-Code von {r_host}...")
|
||||
try:
|
||||
zip_path = os.path.join(install_dir, "worker.zip")
|
||||
urllib.request.urlretrieve(
|
||||
f"http://{r_host}/api/worker-setup/paket", zip_path
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(install_dir)
|
||||
os.remove(zip_path)
|
||||
except Exception as ex:
|
||||
log(f"FEHLER beim Download: {ex}")
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
return
|
||||
|
||||
# Setup venv — --clear loescht einen alten venv komplett, damit keine
|
||||
# veralteten Pakete (z.B. flet 0.86 statt 0.23) ueberleben.
|
||||
log("Lege Python-Umgebung an (venv)...")
|
||||
subprocess.run(
|
||||
f"{python_exe} -m venv --clear venv", cwd=install_dir, shell=True
|
||||
)
|
||||
|
||||
log("Installiere Abhaengigkeiten (pip)...")
|
||||
subprocess.run(
|
||||
r"venv\Scripts\python.exe -m pip install --quiet --upgrade pip",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
subprocess.run(
|
||||
r"venv\Scripts\pip.exe install --quiet -r requirements.txt",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
subprocess.run(
|
||||
r"venv\Scripts\pip.exe install --quiet requests pillow",
|
||||
cwd=install_dir,
|
||||
shell=True,
|
||||
)
|
||||
|
||||
# Download Handbrake
|
||||
hb_path = os.path.join(install_dir, "HandBrakeCLI.exe")
|
||||
if not os.path.exists(hb_path):
|
||||
log("Lade HandBrakeCLI herunter...")
|
||||
try:
|
||||
hb_ver = "1.7.3" # Fallback if github fails
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"https://api.github.com/repos/HandBrake/HandBrake/releases/latest",
|
||||
timeout=5,
|
||||
) as r:
|
||||
hb_data = json.loads(r.read().decode())
|
||||
if hb_data.get("tag_name"):
|
||||
hb_ver = hb_data["tag_name"].lstrip("v")
|
||||
except Exception:
|
||||
pass
|
||||
hb_zip = os.path.join(install_dir, "hb.zip")
|
||||
urllib.request.urlretrieve(
|
||||
f"https://github.com/HandBrake/HandBrake/releases/download/{hb_ver}/HandBrakeCLI-{hb_ver}-win-x86_64.zip",
|
||||
hb_zip,
|
||||
)
|
||||
with zipfile.ZipFile(hb_zip, "r") as zip_ref:
|
||||
zip_ref.extractall(install_dir)
|
||||
os.remove(hb_zip)
|
||||
except Exception as ex:
|
||||
log(f"Warnung bei HandBrake-Download: {ex}")
|
||||
|
||||
log("Erstelle Start-Skripte...")
|
||||
slots = txt_slots.value
|
||||
pool_arg = (
|
||||
"--pool=solo"
|
||||
if int(slots) <= 1
|
||||
else f"--pool=threads --concurrency={slots}"
|
||||
)
|
||||
map_zeile = (
|
||||
f"set RIPPY_PATH_MAP={txt_map.value.strip()}\r\n"
|
||||
if txt_map.value.strip()
|
||||
else ""
|
||||
)
|
||||
|
||||
gui_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_TRAY_HOST={r_host}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nstart "" venv\\Scripts\\pythonw.exe gui.py'
|
||||
with open(os.path.join(install_dir, "start-gui.bat"), "w") as f:
|
||||
f.write(gui_bat)
|
||||
|
||||
work_bat = f'@echo off\r\ncd /d "%~dp0"\r\nset REDIS_URL=redis://{r_host}:6379/0\r\nset DATABASE_URL=postgresql://rippy:rippy@{r_host}:5432/rippy\r\nset API_URL=http://{r_host}:8000\r\nset WORKER_NAME={w_name}\r\nset RIPPY_SLOTS={slots}\r\nset TZ=UTC\r\n{map_zeile}set PATH=%~dp0;%PATH%\r\nvenv\\Scripts\\celery.exe -A celery_app worker --loglevel=info -Q transcode {pool_arg} -n {w_name}@%%h'
|
||||
with open(os.path.join(install_dir, "start-worker.bat"), "w") as f:
|
||||
f.write(work_bat)
|
||||
|
||||
# Uninstaller
|
||||
uninstall_ps1 = f"""param([switch]$Force)
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
if (-not $Force) {{
|
||||
$antwort = [System.Windows.Forms.MessageBox]::Show("Rippy-Worker '{w_name}' von diesem PC entfernen?", "Rippy Worker deinstallieren", 4, 32)
|
||||
if ($antwort -ne "Yes") {{ exit }}
|
||||
}}
|
||||
Get-WmiObject Win32_Process | Where-Object {{
|
||||
$_.ExecutablePath -like "$PSScriptRoot*" -or $_.CommandLine -like "*gui.py*" -or $_.CommandLine -like "*tray.py*"
|
||||
}} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
|
||||
Start-Sleep 3
|
||||
$desktopDir = if ($PSScriptRoot -match "(?i)Program Files") {{ [Environment]::GetFolderPath("CommonDesktopDirectory") }} else {{ [Environment]::GetFolderPath("Desktop") }}
|
||||
Remove-Item -Force "$desktopDir\\Rippy Worker.lnk" -ErrorAction SilentlyContinue
|
||||
schtasks /delete /tn "RippyWorker" /f *>$null
|
||||
Start-Process cmd -ArgumentList "/c ping 127.0.0.1 -n 3 >nul & rmdir /s /q `"$PSScriptRoot`"" -WindowStyle Hidden
|
||||
if (-not $Force) {{ [System.Windows.Forms.MessageBox]::Show("Rippy-Worker entfernt.", "Rippy Worker") }}
|
||||
"""
|
||||
with open(
|
||||
os.path.join(install_dir, "uninstall.ps1"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(uninstall_ps1)
|
||||
|
||||
# Autostart
|
||||
if chk_auto.value:
|
||||
log("Richte Autostart (Task Scheduler) ein...")
|
||||
bat_path = os.path.join(install_dir, "start-gui.bat")
|
||||
subprocess.run(
|
||||
f'schtasks /create /tn "RippyWorker" /tr "\\"{bat_path}\\"" /sc ONLOGON /rl HIGHEST /f',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Desktop Shortcut — ueber PowerShell/COM, weil win32com im
|
||||
# PyInstaller-Bundle nicht enthalten ist.
|
||||
if chk_desktop.value:
|
||||
try:
|
||||
public_desktop = os.path.join(
|
||||
os.environ.get("PUBLIC", r"C:\Users\Public"), "Desktop"
|
||||
)
|
||||
user_desktop = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
desktop = (
|
||||
public_desktop
|
||||
if "Program Files" in install_dir and os.path.isdir(public_desktop)
|
||||
else user_desktop
|
||||
)
|
||||
lnk_path = os.path.join(desktop, "Rippy Worker.lnk")
|
||||
bat_target = os.path.join(install_dir, "start-gui.bat")
|
||||
ico_path = os.path.join(install_dir, "rippy.ico")
|
||||
ps_cmd = (
|
||||
f"$ws = New-Object -ComObject WScript.Shell; "
|
||||
f'$sc = $ws.CreateShortcut("{lnk_path}"); '
|
||||
f'$sc.TargetPath = "{bat_target}"; '
|
||||
f'$sc.WorkingDirectory = "{install_dir}"; '
|
||||
f'$sc.IconLocation = "{ico_path}"; '
|
||||
f"$sc.WindowStyle = 7; "
|
||||
f"$sc.Save()"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps_cmd],
|
||||
capture_output=True,
|
||||
)
|
||||
log("Desktop-Verknüpfung erstellt.")
|
||||
except Exception as e:
|
||||
log(f"Warnung: Shortcut konnte nicht erstellt werden: {e}")
|
||||
|
||||
log("FERTIG!")
|
||||
btn_start.visible = True
|
||||
btn_install.text = "Neu installieren"
|
||||
btn_install.disabled = False
|
||||
page.update()
|
||||
|
||||
def start_worker(e):
|
||||
install_dir = txt_pfad.value.strip()
|
||||
bat_path = os.path.join(install_dir, "start-gui.bat")
|
||||
# os.startfile startet die .bat ueber die Shell-Verknuepfung —
|
||||
# CREATE_NO_WINDOW wuerde sie stumm schlucken.
|
||||
os.startfile(bat_path)
|
||||
page.window_close()
|
||||
|
||||
btn_install = ft.ElevatedButton(
|
||||
"Installieren",
|
||||
on_click=do_install,
|
||||
bgcolor=c_accent,
|
||||
color=ft.colors.WHITE,
|
||||
width=200,
|
||||
height=40,
|
||||
)
|
||||
btn_start = ft.ElevatedButton(
|
||||
"Worker starten",
|
||||
on_click=start_worker,
|
||||
bgcolor=c_success,
|
||||
color=ft.colors.WHITE,
|
||||
width=200,
|
||||
height=40,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
def get_dir(e: ft.FilePickerResultEvent):
|
||||
if e.path:
|
||||
txt_pfad.value = e.path
|
||||
page.update()
|
||||
|
||||
dir_picker = ft.FilePicker(on_result=get_dir)
|
||||
page.overlay.append(dir_picker)
|
||||
|
||||
page.add(
|
||||
ft.Row(
|
||||
[
|
||||
ft.Icon(ft.icons.DISC_FULL, size=40, color=c_accent),
|
||||
ft.Text(
|
||||
"Rippy Encoding-Worker",
|
||||
size=28,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=c_text,
|
||||
),
|
||||
]
|
||||
),
|
||||
ft.Text(
|
||||
"Diese Maschine übernimmt die Video-Kompression für Rippy. Gerippt wird weiter auf der Hauptmaschine.",
|
||||
color=c_muted,
|
||||
),
|
||||
ft.Divider(color="#334155"),
|
||||
ft.Row([txt_host], alignment=ft.MainAxisAlignment.START),
|
||||
ft.Row([txt_name, txt_slots], alignment=ft.MainAxisAlignment.START),
|
||||
ft.Row(
|
||||
[
|
||||
txt_pfad,
|
||||
ft.ElevatedButton(
|
||||
"Durchsuchen ...",
|
||||
on_click=lambda _: dir_picker.get_directory_path(
|
||||
"Installationsordner wählen"
|
||||
),
|
||||
bgcolor=c_panel,
|
||||
color=c_text,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
txt_map,
|
||||
ft.ElevatedButton(
|
||||
"Freigabe holen", on_click=check_map, bgcolor=c_panel, color=c_text
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
),
|
||||
chk_auto,
|
||||
chk_desktop,
|
||||
ft.Container(
|
||||
content=log_view,
|
||||
bgcolor="#020617",
|
||||
padding=10,
|
||||
border_radius=5,
|
||||
border=ft.border.all(1, "#334155"),
|
||||
),
|
||||
ft.Row([btn_install, btn_start]),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.app(target=main)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+91
-18
@@ -11,13 +11,17 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/api/Dockerfile
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
||||
- OMDB_API_KEY=${OMDB_API_KEY}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||
- OMDB_API_KEY=${OMDB_API_KEY:-}
|
||||
# Dasselbe Host-Verzeichnis wie beim Worker, nur an neutraler Stelle:
|
||||
# die API liest den KEYDB-Status und die AACS-Dumps und nimmt eine
|
||||
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
||||
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
||||
- LOG_LEVEL=INFO
|
||||
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||
interval: 30s
|
||||
@@ -26,11 +30,32 @@ services:
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# SYS_ADMIN: die API hängt Netzwerk-Speicherziele (NFS/SMB) selbst ein
|
||||
# (mounts.py) — dank rshared-Propagation unten sehen Host UND Worker
|
||||
# jeden Mount sofort.
|
||||
# DAC_READ_SEARCH: mount.cifs hebt diese Capability an (toggle_dac_
|
||||
# capability) — sie fehlt in Dockers Default-Set, ohne sie stirbt JEDER
|
||||
# SMB-Mount mit "Unable to apply new capability set" (Befund 24.07.,
|
||||
# auf der VM reproduziert und mit dieser Capability bewiesen behoben).
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
- DAC_READ_SEARCH
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
volumes:
|
||||
- media:/app/media
|
||||
# /srv/rippy/media auf der VM statt anonymem Volume: rshared lässt
|
||||
# Mounts aus DIESEM Container zum Host und in andere Container
|
||||
# propagieren (Commander-Anforderung 23.07.: Ziele frei wählbar).
|
||||
- type: bind
|
||||
source: /srv/rippy/media
|
||||
target: /app/media
|
||||
bind:
|
||||
propagation: rshared
|
||||
- temp:/app/temp
|
||||
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -45,23 +70,63 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/worker/Dockerfile
|
||||
args:
|
||||
# Übersteuerbar via .env. /download = aktuelle Version,
|
||||
# /download/old = ältere. Am 25.07.2026 nachgemessen: /download
|
||||
# antwortet mit 200, /download/old mit 525 (Cloudflare), und ein
|
||||
# web.archive.org-Schnappschuss mit 404 — deshalb steht hier der
|
||||
# Standard, der wirklich liefert.
|
||||
MAKEMKV_URL_BASE: ${MAKEMKV_URL_BASE:-https://www.makemkv.com/download}
|
||||
# Zweite Quelle, die bei Fehlschlag der ersten automatisch probiert wird
|
||||
# (leer = keine). Bewusst ohne Vorbelegung: eine Adresse, die nicht
|
||||
# liefert, lässt die Konfiguration gesund aussehen und den Build später
|
||||
# scheitern. Der zuverlässige Weg bleibt docker/worker/vendor/.
|
||||
MAKEMKV_URL_FALLBACK: ${MAKEMKV_URL_FALLBACK:-}
|
||||
# MakeMKV-Update OHNE Code-Änderung: neue Version in die .env,
|
||||
# Tarballs nach docker/worker/vendor/ legen (oder Download klappt),
|
||||
# dann docker compose build worker && docker compose up -d worker.
|
||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||
# MakeMKVs Datenverzeichnis im Container — fest verdrahtet in MakeMKV
|
||||
# selbst (HOME/.MakeMKV, HOME ist /root). Steht hier, damit Worker-Code
|
||||
# und Mount dieselbe Wahrheit benutzen.
|
||||
- MAKEMKV_DATA_DIR=/root/.MakeMKV
|
||||
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
||||
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
||||
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
||||
# Für Serien-Episoden-Matching: der Worker fragt die API nach den
|
||||
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||
- API_URL=http://api:8000
|
||||
- LOG_LEVEL=INFO
|
||||
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- type: bind
|
||||
source: /srv/rippy/media
|
||||
target: /app/media
|
||||
bind:
|
||||
propagation: rslave
|
||||
- temp:/app/temp
|
||||
# MakeMKV-Datenverzeichnis PERSISTENT (Befund 25.07.2026, live gemessen):
|
||||
# Hier liegen die KEYDB.cfg — heute die EINZIGE funktionierende
|
||||
# Schlüsselquelle für 4K-UHD, weil MakeMKVs Online-Kanal nichts mehr
|
||||
# liefert —, die AACS-Dumps fehlgeschlagener Discs und settings.conf.
|
||||
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
||||
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
||||
# neu starten.
|
||||
- /dev/sg1:/dev/sg1
|
||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -94,8 +159,14 @@ services:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=rippy
|
||||
- POSTGRES_PASSWORD=rippy
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||
- POSTGRES_DB=rippy
|
||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||
# ports: konnte sich NIE ein Remote-Worker anbinden. Heimnetz-Kompromiss,
|
||||
# wie die Klartext-Credentials (README).
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -109,6 +180,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
# Siehe postgres: Pflicht für Remote-Worker (Celery-Broker).
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
@@ -126,7 +200,6 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
media:
|
||||
temp:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
|
||||
+23
-1
@@ -3,11 +3,33 @@ FROM python:3.12-slim-bookworm
|
||||
WORKDIR /app
|
||||
|
||||
# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) —
|
||||
# die Geräte-Erkennung läuft jetzt über /sys + ioctls, ganz ohne Systempakete.
|
||||
# die Geräte-Erkennung läuft jetzt über /sys + ioctls.
|
||||
# nfs-common/cifs-utils: Netzwerk-Speicherziele werden aus dem UI heraus
|
||||
# eingehängt (mounts.py, braucht CAP_SYS_ADMIN aus dem Compose).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nfs-common \
|
||||
cifs-utils \
|
||||
smbclient \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker/api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY docker/api/ .
|
||||
|
||||
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
|
||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||
# die Zielmaschine braucht weder git noch Docker.
|
||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||
COPY deploy/worker-windows/installer.py worker_dist/
|
||||
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
|
||||
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
|
||||
# Verknüpfung das Standard-Batch-Symbol — und niemand fände sie zwischen den
|
||||
# anderen Icons wieder.
|
||||
COPY deploy/worker-windows/rippy.ico worker_dist/
|
||||
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||
COPY deploy/worker-windows/RippyWorkerSetup.exe worker_dist/
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""JWT-Auth-Module für Rippy API."""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from config import settings
|
||||
|
||||
# Passwort-Hashing-Kontext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
|
||||
# Review-Fund 22.07.: der frühere Zufalls-Fallback erzeugte PRO PROZESS einen
|
||||
# neuen Schlüssel → jeder Neustart/zweite Worker invalidierte alle Tokens.
|
||||
# Lieber laut scheitern als still kaputt sein.
|
||||
if not settings.jwt_secret_key:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET_KEY ist nicht gesetzt (.env). Ohne festen Schlüssel wären "
|
||||
"alle Tokens nach jedem Neustart ungültig — Start verweigert."
|
||||
)
|
||||
SECRET_KEY = settings.jwt_secret_key
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
# Token-Lifetimes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verifiziere Passwort."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash Passwort."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
|
||||
"""Erstelle Access Token (Default 15 min)."""
|
||||
to_encode = data.copy()
|
||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.now(timezone.utc) + delta
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict) -> str:
|
||||
"""Erstelle Refresh Token (7 Tage)."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict]:
|
||||
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def is_access_token(token: str) -> bool:
|
||||
"""Prüfe ob Token ein gültiges Access Token ist."""
|
||||
payload = decode_token(token)
|
||||
return bool(payload and payload.get("type") == "access")
|
||||
|
||||
|
||||
def is_refresh_token(token: str) -> bool:
|
||||
"""Prüfe ob Token ein gültiges Refresh Token ist."""
|
||||
payload = decode_token(token)
|
||||
return bool(payload and payload.get("type") == "refresh")
|
||||
|
||||
|
||||
# Token-Blacklist für Logout: Token → Ablauf-Zeitstempel (exp).
|
||||
# Bewusste MVP-Grenze: in-memory = pro Prozess (siehe SAVEPOINT.md).
|
||||
# Review-Fund 22.07.: das frühere cleanup löschte die GESAMTE Blacklist —
|
||||
# Logout war ein Placebo. Jetzt fliegen nur abgelaufene Tokens raus
|
||||
# (die sind eh ungültig, decode_token lehnt sie ab).
|
||||
token_blacklist: Dict[str, float] = {}
|
||||
|
||||
|
||||
def add_to_blacklist(token: str) -> None:
|
||||
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
|
||||
payload = decode_token(token)
|
||||
if payload:
|
||||
token_blacklist[token] = float(payload.get("exp", time.time()))
|
||||
|
||||
|
||||
def is_blacklisted(token: str) -> bool:
|
||||
"""Prüfe ob Token auf der Blacklist steht."""
|
||||
return token in token_blacklist
|
||||
|
||||
|
||||
def cleanup_blacklist() -> None:
|
||||
"""Entferne NUR abgelaufene Tokens von der Blacklist."""
|
||||
now = time.time()
|
||||
for token in [t for t, exp in token_blacklist.items() if exp <= now]:
|
||||
del token_blacklist[token]
|
||||
Vendored
+8
-3
@@ -30,10 +30,15 @@ FALLBACK_YEAR: Final[int] = 2000
|
||||
FALLBACK_TITLE: Final[str] = "Unbekannter Titel"
|
||||
|
||||
|
||||
def generate_prescan_key(device_path: str, is_audio: bool = False) -> str:
|
||||
"""Generiere Cache-Key für Pre-Scan."""
|
||||
def generate_prescan_key(device_path: str, is_audio: bool = False, fingerprint: str = "") -> str:
|
||||
"""Generiere Cache-Key für Pre-Scan.
|
||||
|
||||
fingerprint (Label+Größe der DISC) ist Pflichtbestandteil seit 23.07.:
|
||||
Ein Key nur aus dem Gerätepfad servierte der NÄCHSTEN Disc im selben
|
||||
Laufwerk die Metadaten der vorherigen.
|
||||
"""
|
||||
prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX
|
||||
return f"{prefix}{device_path}"
|
||||
return f"{prefix}{device_path}:{fingerprint}"
|
||||
|
||||
|
||||
def generate_confirmed_key(title: str, year: Optional[int] = None) -> str:
|
||||
|
||||
@@ -7,14 +7,35 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.utils import worker_direct
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||
|
||||
|
||||
def start_rip(device_path: str, job_id: str):
|
||||
def transcode_queue(node: str = None):
|
||||
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
|
||||
wenn er gerade online ist, sonst die geteilte transcode-Queue.
|
||||
|
||||
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
|
||||
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
|
||||
landet bei irgendeinem freien Worker.
|
||||
"""
|
||||
if not node:
|
||||
return "transcode"
|
||||
try:
|
||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||
online = {k for antwort in antworten for k in antwort.keys()}
|
||||
if node in online:
|
||||
return worker_direct(node)
|
||||
except Exception:
|
||||
pass
|
||||
return "transcode"
|
||||
|
||||
|
||||
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||
return celery_client.send_task(
|
||||
"worker.tasks.rip_disc", args=[device_path, job_id]
|
||||
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key.
|
||||
|
||||
Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die
|
||||
präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und
|
||||
Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant).
|
||||
Doku: https://docs.api.jikan.moe/
|
||||
"""
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from cache import get, set as cache_set
|
||||
|
||||
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
|
||||
|
||||
|
||||
def titel_aehnlichkeit(a: str, b: str) -> float:
|
||||
"""Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar)."""
|
||||
def norm(t: str) -> str:
|
||||
return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip()
|
||||
|
||||
a_n, b_n = norm(a), norm(b)
|
||||
if not a_n or not b_n:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, a_n, b_n).ratio()
|
||||
|
||||
|
||||
class JikanClient:
|
||||
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
|
||||
MINDEST_AEHNLICHKEIT = 0.55
|
||||
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
|
||||
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
|
||||
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
|
||||
#
|
||||
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
|
||||
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
|
||||
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
|
||||
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
|
||||
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
|
||||
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
|
||||
SICHER_AEHNLICHKEIT = 0.9
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
|
||||
def suche(self, title: str) -> list:
|
||||
"""Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate)."""
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{JIKAN_BASE_URL}/anime",
|
||||
params={"q": title, "limit": 5, "sfw": "true"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
eintraege = response.json().get("data") or []
|
||||
except Exception:
|
||||
return []
|
||||
ergebnisse = []
|
||||
for e in eintraege:
|
||||
bilder = (e.get("images") or {}).get("jpg") or {}
|
||||
ergebnisse.append({
|
||||
"title": e.get("title_english") or e.get("title") or "",
|
||||
"year": e.get("year"),
|
||||
"poster": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
||||
"overview": (e.get("synopsis") or "")[:200],
|
||||
"type": "movie" if e.get("type") == "Movie" else "tv",
|
||||
"source": "jikan",
|
||||
"id": f"mal-{e.get('mal_id', '')}",
|
||||
})
|
||||
return ergebnisse
|
||||
|
||||
def lookup(self, title: str) -> Optional[Dict]:
|
||||
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
||||
cache_key = f"jikan:{title.lower()}"
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Jikan wirft sporadisch 504 (Gratis-Dienst) — ein Retry rettet die
|
||||
# meisten Fälle, mehr wäre Belästigung.
|
||||
eintraege = None
|
||||
for versuch in range(2):
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{JIKAN_BASE_URL}/anime",
|
||||
params={"q": title, "limit": 5, "sfw": "true"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
eintraege = response.json().get("data") or []
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Jikan API Error (Versuch {versuch + 1}): {e}")
|
||||
if versuch == 0:
|
||||
import time
|
||||
time.sleep(2)
|
||||
if eintraege is None:
|
||||
return None
|
||||
|
||||
bester, bester_score = None, 0.0
|
||||
for eintrag in eintraege:
|
||||
varianten = [eintrag.get("title") or ""]
|
||||
if eintrag.get("title_english"):
|
||||
varianten.append(eintrag["title_english"])
|
||||
score = max(titel_aehnlichkeit(title, v) for v in varianten)
|
||||
if score > bester_score:
|
||||
bester, bester_score = eintrag, score
|
||||
|
||||
if not bester or bester_score < self.MINDEST_AEHNLICHKEIT:
|
||||
return None
|
||||
|
||||
bilder = (bester.get("images") or {}).get("jpg") or {}
|
||||
ergebnis = {
|
||||
"type": "movie" if bester.get("type") == "Movie" else "tv",
|
||||
"id": f"mal-{bester.get('mal_id', '')}",
|
||||
"title": bester.get("title_english") or bester.get("title") or title,
|
||||
"year": bester.get("year"),
|
||||
"overview": (bester.get("synopsis") or "")[:600],
|
||||
"poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
||||
"backdrop_path": "",
|
||||
"runtime": 0,
|
||||
"genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")],
|
||||
"source": "jikan",
|
||||
"match_score": round(bester_score, 2),
|
||||
}
|
||||
cache_set(cache_key, ergebnis)
|
||||
return ergebnis
|
||||
@@ -32,9 +32,39 @@ def parse_year(year: str) -> Optional[int]:
|
||||
|
||||
class OMDbClient:
|
||||
def __init__(self):
|
||||
self.api_key = settings.omdb_api_key
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
|
||||
self.session = requests.Session()
|
||||
|
||||
def suche(self, title: str) -> list:
|
||||
"""Roh-Kandidaten für die manuelle Korrektur (unscharfe s=-Suche)."""
|
||||
if not self.api_key:
|
||||
return []
|
||||
try:
|
||||
antwort = self.session.get(
|
||||
OMDB_BASE_URL,
|
||||
params={"apikey": self.api_key, "s": title, "r": "json"},
|
||||
timeout=10,
|
||||
).json()
|
||||
except Exception:
|
||||
return []
|
||||
if antwort.get("Response") != "True":
|
||||
return []
|
||||
ergebnisse = []
|
||||
for e in (antwort.get("Search") or [])[:5]:
|
||||
poster = e.get("Poster", "")
|
||||
ergebnisse.append({
|
||||
"title": e.get("Title", ""),
|
||||
"year": parse_year(e.get("Year", "")),
|
||||
"poster": poster if poster != "N/A" else "",
|
||||
"overview": "",
|
||||
"type": "tv" if e.get("Type") == "series" else "movie",
|
||||
"source": "omdb",
|
||||
"id": e.get("imdbID", ""),
|
||||
})
|
||||
return ergebnisse
|
||||
|
||||
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
|
||||
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
|
||||
if not self.api_key:
|
||||
@@ -53,6 +83,33 @@ class OMDbClient:
|
||||
response = self.session.get(OMDB_BASE_URL, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# t= ist exakt — wenn leer, unscharfe Suche (s=) und den besten
|
||||
# Treffer per imdbID nachladen
|
||||
if data.get("Response") != "True":
|
||||
suche = self.session.get(
|
||||
OMDB_BASE_URL,
|
||||
params={"apikey": self.api_key, "s": title, "r": "json"},
|
||||
timeout=10,
|
||||
).json()
|
||||
treffer = (suche.get("Search") or []) if suche.get("Response") == "True" else []
|
||||
if treffer:
|
||||
# Bester Kandidat statt blind Treffer #1: echte Filme vor
|
||||
# Episoden/Reviews, mit Poster vor ohne, kurzer Titel vor
|
||||
# langem (Zusatz-Geschwafel wie "Episode 110 - …" verliert).
|
||||
def rang(t: Dict):
|
||||
return (
|
||||
0 if t.get("Type") == "movie" else (1 if t.get("Type") == "series" else 2),
|
||||
0 if t.get("Poster") not in (None, "", "N/A") else 1,
|
||||
len(t.get("Title") or ""),
|
||||
)
|
||||
|
||||
bester = sorted(treffer, key=rang)[0]
|
||||
data = self.session.get(
|
||||
OMDB_BASE_URL,
|
||||
params={"apikey": self.api_key, "i": bester["imdbID"], "r": "json"},
|
||||
timeout=10,
|
||||
).json()
|
||||
except Exception as e:
|
||||
print(f"OMDb API Error: {e}")
|
||||
return None
|
||||
@@ -60,16 +117,29 @@ class OMDbClient:
|
||||
if data.get("Response") != "True":
|
||||
return None
|
||||
|
||||
plot = data.get("Plot", "")
|
||||
if plot == "N/A":
|
||||
plot = ""
|
||||
poster = data.get("Poster", "")
|
||||
if poster == "N/A":
|
||||
poster = ""
|
||||
|
||||
# Qualitäts-Gate: Einträge ohne Poster UND ohne Beschreibung sind
|
||||
# fast immer Datenmüll (Review-Episoden etc.) — als Nicht-Treffer
|
||||
# werten, damit die Kandidaten-Kette weiterprobiert.
|
||||
if not poster and not plot:
|
||||
return None
|
||||
|
||||
ergebnis = {
|
||||
"type": "tv" if data.get("Type") == "series" else "movie",
|
||||
"id": data.get("imdbID", ""),
|
||||
"title": data.get("Title", title),
|
||||
"year": parse_year(data.get("Year", "")),
|
||||
"overview": data.get("Plot", ""),
|
||||
"poster_path": data.get("Poster", "") if data.get("Poster") != "N/A" else "",
|
||||
"overview": plot,
|
||||
"poster_path": poster,
|
||||
"backdrop_path": "",
|
||||
"runtime": parse_runtime_minutes(data.get("Runtime", "")),
|
||||
"genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip()],
|
||||
"genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip() and g.strip() != "N/A"],
|
||||
"source": "omdb",
|
||||
}
|
||||
cache_set(cache_key, ergebnis)
|
||||
|
||||
@@ -12,7 +12,9 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
|
||||
|
||||
class TheTVDBClient:
|
||||
def __init__(self):
|
||||
self.api_key = settings.thetvdb_api_key
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
|
||||
self.base_url = THETVDB_BASE_URL
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
|
||||
@@ -11,18 +11,35 @@ TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
||||
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||
|
||||
|
||||
def ist_v4_token(key: str) -> bool:
|
||||
"""Pure Funktion (testbar): TMDB hat ZWEI Key-Arten — der v4 Read Access
|
||||
Token ist ein langes JWT ('eyJ…', Bearer-Header), der klassische v3-Key
|
||||
32 Hex-Zeichen (api_key-Query-Parameter). Befund 24.07.: der Client konnte
|
||||
NUR v4 — mit dem üblichen v3-Key aus dem TMDB-Konto war jede Anfrage 401
|
||||
und die Suche lieferte still nur OMDb-Treffer."""
|
||||
return bool(key) and key.startswith("eyJ")
|
||||
|
||||
|
||||
class TMDBClient:
|
||||
def __init__(self):
|
||||
self.api_key = settings.tmdb_api_key
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
||||
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
self.session.headers.update({"Content-Type": "application/json"})
|
||||
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
||||
# api_key-Parameter, v4-Token als Bearer-Header)
|
||||
self._key_params = {}
|
||||
if ist_v4_token(self.api_key):
|
||||
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
||||
elif self.api_key:
|
||||
self._key_params = {"api_key": self.api_key}
|
||||
|
||||
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||
"""Mache API-Request mit Caching."""
|
||||
from cache.keys import generate_tmdb_key
|
||||
# Cache-Key OHNE den api_key — der gehört nicht in den Cache
|
||||
cache_key = generate_tmdb_key(endpoint, params)
|
||||
|
||||
cached = get(cache_key)
|
||||
@@ -32,7 +49,7 @@ class TMDBClient:
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{TMDB_BASE_URL}/{endpoint}",
|
||||
params=params,
|
||||
params={**(params or {}), **self._key_params},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -79,6 +96,42 @@ class TMDBClient:
|
||||
params = {"language": "de-DE"}
|
||||
return self._request(f"tv/{tv_id}", params)
|
||||
|
||||
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
|
||||
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
|
||||
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
|
||||
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
|
||||
|
||||
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
|
||||
developer.themoviedb.org/reference/find-by-id).
|
||||
|
||||
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
|
||||
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
|
||||
"""
|
||||
result = self._request(
|
||||
f"find/{imdb_id}",
|
||||
{"external_source": "imdb_id", "language": "de-DE"},
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
for schluessel, typ, titel_feld, datum_feld in (
|
||||
("movie_results", "movie", "title", "release_date"),
|
||||
("tv_results", "tv", "name", "first_air_date"),
|
||||
):
|
||||
eintraege = result.get(schluessel) or []
|
||||
if eintraege:
|
||||
e = eintraege[0]
|
||||
datum = e.get(datum_feld) or ""
|
||||
return {
|
||||
"type": typ,
|
||||
"tmdb_id": e.get("id"),
|
||||
"title": e.get(titel_feld) or "",
|
||||
"overview": e.get("overview") or "",
|
||||
"poster_path": e.get("poster_path") or "",
|
||||
"year": int(datum[:4]) if datum[:4].isdigit() else None,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
||||
"""Hole Poster/Fanart URLs."""
|
||||
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
||||
|
||||
@@ -24,12 +24,8 @@ class Settings(BaseSettings):
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
||||
|
||||
# JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr)
|
||||
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)")
|
||||
|
||||
# Admin-Login (MVP — vorher hartkodiert admin/rippy123 in main.py)
|
||||
admin_username: str = Field(default="admin", description="Admin-Benutzername")
|
||||
admin_password: str = Field(default="rippy123", description="Admin-Passwort (per .env ÄNDERN!)")
|
||||
# JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
|
||||
# Heimnetz-only, es gab nie einen Login-Flow im UI — siehe KONZEPT §10.
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
||||
try:
|
||||
settings = Settings()
|
||||
|
||||
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
|
||||
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
|
||||
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
|
||||
# sich jederzeit im UI (Einstellungen → System) nachtragen.
|
||||
if not settings.tmdb_api_key:
|
||||
raise ConfigValidationError(
|
||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
||||
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||
)
|
||||
|
||||
return settings
|
||||
@@ -48,10 +50,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
|
||||
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
||||
)
|
||||
|
||||
# Optional: JWT Secret (wird generiert, wenn nicht gesetzt)
|
||||
if not settings.jwt_secret_key:
|
||||
warnings.append(
|
||||
"JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet."
|
||||
)
|
||||
|
||||
return settings, warnings
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
|
||||
"""Pytest-Setup für API-Tests: flache Modul-Imports."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
|
||||
|
||||
+134
-2
@@ -41,7 +41,9 @@ jobs = Table(
|
||||
Column("status", String(16), nullable=False, server_default="pending"),
|
||||
Column("progress", Integer, nullable=False, server_default="0"),
|
||||
Column("output_path", Text),
|
||||
Column("target_dir", String(255)),
|
||||
Column("error", Text),
|
||||
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
|
||||
Column("created_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
)
|
||||
@@ -63,17 +65,93 @@ settings_table = Table(
|
||||
Column("value", Text),
|
||||
)
|
||||
|
||||
workers = Table(
|
||||
"workers",
|
||||
metadata,
|
||||
Column("name", String(128), primary_key=True),
|
||||
Column("encoders", Text),
|
||||
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||
Column("last_seen", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
storage_mounts = Table(
|
||||
"storage_mounts",
|
||||
metadata,
|
||||
Column("name", String(64), primary_key=True),
|
||||
Column("typ", String(8)), # nfs | cifs
|
||||
Column("quelle", String(255)), # host:/export bzw. //host/share
|
||||
Column("optionen", String(255)),
|
||||
Column("username", String(128)),
|
||||
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
|
||||
)
|
||||
|
||||
|
||||
def list_workers() -> list:
|
||||
import json
|
||||
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(select(workers)).mappings().all()
|
||||
ergebnis = []
|
||||
for z in zeilen:
|
||||
eintrag = dict(z)
|
||||
try:
|
||||
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
|
||||
except ValueError:
|
||||
eintrag["encoders"] = []
|
||||
try:
|
||||
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
|
||||
except ValueError:
|
||||
eintrag["info"] = {}
|
||||
if eintrag.get("last_seen"):
|
||||
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
|
||||
ergebnis.append(eintrag)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def list_mounts() -> list:
|
||||
with engine.connect() as conn:
|
||||
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
|
||||
|
||||
|
||||
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
storage_mounts.insert().values(
|
||||
name=name, typ=typ, quelle=quelle,
|
||||
optionen=optionen, username=username, passwort=passwort,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def delete_mount(name: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Legt fehlende Tabellen an (idempotent)."""
|
||||
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
|
||||
metadata.create_all(engine)
|
||||
# create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen:
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None:
|
||||
def insert_job(
|
||||
job_id: str, device: str, disc_type: str = None, title: str = None,
|
||||
target_dir: str = None, meta: str = None,
|
||||
) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
jobs.insert().values(
|
||||
@@ -81,6 +159,8 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
|
||||
device=device,
|
||||
disc_type=disc_type,
|
||||
title=title,
|
||||
target_dir=target_dir,
|
||||
meta=meta,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_at=utcnow(),
|
||||
@@ -88,6 +168,19 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
|
||||
)
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict:
|
||||
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 list_jobs(limit: int = 100) -> list:
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
@@ -96,6 +189,45 @@ def list_jobs(limit: int = 100) -> list:
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def delete_job(job_id: str) -> None:
|
||||
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(jobs.delete().where(jobs.c.id == job_id))
|
||||
|
||||
|
||||
def delete_finished_jobs() -> int:
|
||||
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
|
||||
with engine.begin() as conn:
|
||||
ergebnis = conn.execute(
|
||||
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
|
||||
)
|
||||
return ergebnis.rowcount or 0
|
||||
|
||||
|
||||
def delete_worker(name: str) -> None:
|
||||
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(workers.delete().where(workers.c.name == name))
|
||||
|
||||
|
||||
def hat_arbeit() -> bool:
|
||||
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
|
||||
|
||||
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
|
||||
`umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein
|
||||
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
|
||||
anlaufen.
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.id)
|
||||
.where(jobs.c.status.in_(
|
||||
("pending", "running", "ripping", "transcoding", "canceling")))
|
||||
.limit(1)
|
||||
).first()
|
||||
return zeile is not None
|
||||
|
||||
|
||||
def has_active_job(device: str) -> bool:
|
||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||
with engine.connect() as conn:
|
||||
|
||||
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
|
||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
|
||||
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
|
||||
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
|
||||
UHD_MIN_BYTES = 55 * 1024**3
|
||||
|
||||
|
||||
def _open_nonblock(device_path: str) -> int:
|
||||
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
|
||||
|
||||
|
||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
|
||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||
return "cd"
|
||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||
if size_bytes >= UHD_MIN_BYTES:
|
||||
return "uhd"
|
||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||
return "unknown"
|
||||
|
||||
|
||||
+35
-1
@@ -20,13 +20,47 @@ from detection import (
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROMEJECT = 0x5309
|
||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
|
||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
||||
|
||||
|
||||
def eject(device_path: str) -> None:
|
||||
"""Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
|
||||
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
|
||||
|
||||
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
|
||||
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
|
||||
und tut nichts. MakeMKV verriegelt die Tür während des Rips
|
||||
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
|
||||
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
|
||||
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
|
||||
Deshalb macht das Werkzeug `eject` immer beides.
|
||||
|
||||
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
|
||||
zwischen den Containern; dort steht die ausführliche Herleitung.
|
||||
"""
|
||||
import time
|
||||
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
try:
|
||||
ioctl(fd, CDROM_LOCKDOOR, 0)
|
||||
except OSError:
|
||||
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
|
||||
# Slot-Laufwerk hat keine Schublade.
|
||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
||||
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise OSError(
|
||||
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
||||
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
||||
)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
|
||||
|
||||
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
|
||||
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
|
||||
**ETA** im Dashboard und beim externen Worker.
|
||||
|
||||
## Warum hier und nicht im Browser
|
||||
|
||||
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
|
||||
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
|
||||
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
|
||||
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
|
||||
gehört also dorthin, wo der Fortschritt ankommt.
|
||||
|
||||
## Warum nicht einfach „vergangene Zeit / Prozent"
|
||||
|
||||
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
|
||||
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
|
||||
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
|
||||
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
|
||||
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
|
||||
|
||||
## Ehrlichkeit vor Zahl
|
||||
|
||||
Lieber „wird noch geschätzt" als eine erfundene Minute:
|
||||
|
||||
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
|
||||
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
|
||||
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
|
||||
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
|
||||
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
|
||||
langsamer, die frühen Werte lügen dann.
|
||||
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
|
||||
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
|
||||
nicht.
|
||||
"""
|
||||
|
||||
MINDEST_PUNKTE = 2
|
||||
MINDEST_SPANNE_SEKUNDEN = 60
|
||||
MINDEST_FORTSCHRITT = 1
|
||||
FENSTER = 10
|
||||
OBERGRENZE_SEKUNDEN = 48 * 3600
|
||||
|
||||
# Status, für die eine Restzeit überhaupt Sinn hat.
|
||||
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
|
||||
|
||||
|
||||
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
|
||||
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
|
||||
|
||||
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
|
||||
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
|
||||
Kompression haben nichts miteinander zu tun).
|
||||
|
||||
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
|
||||
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
|
||||
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
|
||||
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
|
||||
"""
|
||||
alt = reihe or {}
|
||||
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
|
||||
if not punkte or punkte[-1][1] != progress:
|
||||
punkte.append([jetzt, progress])
|
||||
return {"status": status, "punkte": punkte[-FENSTER:]}
|
||||
|
||||
|
||||
def restzeit_sekunden(reihe, jetzt: float) -> int:
|
||||
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
|
||||
|
||||
-1 statt None, damit der Wert unverändert durch JSON und das
|
||||
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
|
||||
Worker, wo -1 „keine Angabe" heißt).
|
||||
"""
|
||||
punkte = (reihe or {}).get("punkte") or []
|
||||
if len(punkte) < MINDEST_PUNKTE:
|
||||
return -1
|
||||
erste_zeit, erster_prozent = punkte[0]
|
||||
letzte_zeit, letzter_prozent = punkte[-1]
|
||||
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
|
||||
# stehender Job dauerhaft die Rate von vor drei Stunden.
|
||||
spanne = max(jetzt, letzte_zeit) - erste_zeit
|
||||
gewachsen = letzter_prozent - erster_prozent
|
||||
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
|
||||
return -1
|
||||
rest_prozent = 100 - letzter_prozent
|
||||
if rest_prozent <= 0:
|
||||
return 0
|
||||
pro_prozent = spanne / gewachsen
|
||||
return int(rest_prozent * pro_prozent)
|
||||
|
||||
|
||||
def formatiere_restzeit(sekunden: int) -> str:
|
||||
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
|
||||
if sekunden is None or sekunden < 0:
|
||||
return ""
|
||||
if sekunden > OBERGRENZE_SEKUNDEN:
|
||||
return "mehr als 2 Tage"
|
||||
if sekunden < 60:
|
||||
return "unter einer Minute"
|
||||
minuten = sekunden // 60
|
||||
if minuten < 60:
|
||||
return f"noch ca. {minuten} min"
|
||||
stunden, rest_minuten = divmod(minuten, 60)
|
||||
if stunden < 24:
|
||||
return f"noch ca. {stunden} h {rest_minuten:02d} min"
|
||||
tage, rest_stunden = divmod(stunden, 24)
|
||||
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
|
||||
|
||||
|
||||
def schluessel(job_id: str) -> str:
|
||||
return f"eta:{job_id}"
|
||||
|
||||
|
||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||
jetzt: float, cache_get, cache_set) -> dict:
|
||||
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
||||
|
||||
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
||||
keine ETA, aber nichts scheitert.
|
||||
"""
|
||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||
return {"sekunden": -1, "text": ""}
|
||||
k = schluessel(job_id)
|
||||
try:
|
||||
reihe = cache_get(k)
|
||||
except Exception:
|
||||
reihe = None
|
||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||
try:
|
||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||
cache_set(k, reihe, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Jellyfin Image Downloader."""
|
||||
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from clients.tmdb import TMDBClient
|
||||
from clients.thetvdb import TheTVDBClient
|
||||
|
||||
|
||||
class ImageDownloader:
|
||||
def __init__(self):
|
||||
self.tmdb = TMDBClient()
|
||||
self.thetvdb = TheTVDBClient()
|
||||
self.base_url = "https://image.tmdb.org/t/p"
|
||||
|
||||
def download_image(self, url: str, output_path: Path, width: int = 500) -> bool:
|
||||
"""Lade Image herunter."""
|
||||
try:
|
||||
# TMDB URL anpassen
|
||||
if url.startswith("https://image.tmdb.org"):
|
||||
# Konvertiere zu gewünschter Größe
|
||||
path = url.replace(f"{self.base_url}/", "")
|
||||
url = f"{self.base_url}/w{width}/{path}"
|
||||
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Image download error: {e}")
|
||||
return False
|
||||
|
||||
def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]:
|
||||
"""Lade Poster herunter."""
|
||||
# TMDB Search
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "poster.jpg"
|
||||
if self.download_image(images["poster"], output_path, width):
|
||||
return output_path
|
||||
|
||||
return None
|
||||
|
||||
def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]:
|
||||
"""Lade Fanart herunter."""
|
||||
# TMDB Search
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("fanart"):
|
||||
output_path = output_dir / "fanart.jpg"
|
||||
if self.download_image(images["fanart"], output_path, width):
|
||||
return output_path
|
||||
|
||||
return None
|
||||
|
||||
def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
||||
"""Lade Serien-Poster und Fanart herunter."""
|
||||
result = {
|
||||
"poster": None,
|
||||
"fanart": None
|
||||
}
|
||||
|
||||
tv_shows = self.tmdb.search_tv(title)
|
||||
if tv_shows:
|
||||
tv = tv_shows[0]
|
||||
images = self.tmdb.get_tv_images(tv["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "poster.jpg"
|
||||
if self.download_image(images["poster"], output_path, 500):
|
||||
result["poster"] = output_path
|
||||
|
||||
if images.get("fanart"):
|
||||
output_path = output_dir / "fanart.jpg"
|
||||
if self.download_image(images["fanart"], output_path, 1920):
|
||||
result["fanart"] = output_path
|
||||
|
||||
return result
|
||||
|
||||
def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
||||
"""Lade Musik-Album-Cover herunter (via TMDB als Fallback)."""
|
||||
result = {
|
||||
"album": None
|
||||
}
|
||||
|
||||
# TMDB Search für Soundtracks
|
||||
query = f"{album} soundtrack"
|
||||
movies = self.tmdb.search_movie(query)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "album.jpg"
|
||||
if self.download_image(images["poster"], output_path, 500):
|
||||
result["album"] = output_path
|
||||
|
||||
return result
|
||||
+2002
-249
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
||||
|
||||
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
||||
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
||||
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
||||
MakeMKV liest die Disc — sondern:
|
||||
|
||||
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
||||
Die Windows-Version tut es.
|
||||
|
||||
Gemessen, nicht vermutet:
|
||||
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
||||
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
||||
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
||||
keine Verbindung, kein Schluessel.
|
||||
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
||||
latest HK to ...", Verbindung nach 185.84.108.20:443
|
||||
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
||||
(TCOUNT:5, "Operation successfully completed").
|
||||
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
||||
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
||||
|
||||
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
||||
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
||||
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
||||
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
||||
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
||||
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
||||
|
||||
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
||||
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
||||
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
||||
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
||||
|
||||
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
||||
|
||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
||||
* Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
||||
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
||||
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
||||
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
||||
https://forum.makemkv.com/forum/viewtopic.php?t=32675
|
||||
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
|
||||
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
|
||||
https://forum.makemkv.com/forum/viewtopic.php?t=30636
|
||||
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
|
||||
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
|
||||
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
|
||||
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
||||
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
|
||||
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
|
||||
|
||||
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
||||
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
||||
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
|
||||
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
|
||||
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
|
||||
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
||||
|
||||
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||
KEYDB_NAME = "KEYDB.cfg"
|
||||
|
||||
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
||||
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
||||
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
||||
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
||||
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
||||
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
||||
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
||||
|
||||
|
||||
def _iso(zeitstempel: float) -> str:
|
||||
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
|
||||
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def keydb_pfad(daten_dir: str = None) -> str:
|
||||
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
|
||||
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
|
||||
|
||||
|
||||
def zaehle_disc_eintraege(inhalt: str) -> int:
|
||||
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
||||
|
||||
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
||||
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
||||
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
||||
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
||||
unterscheiden — sie wird deshalb auch genau so beschriftet.
|
||||
"""
|
||||
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
|
||||
|
||||
|
||||
def keydb_pruefen(inhalt: str) -> str:
|
||||
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
||||
|
||||
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
||||
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
||||
"""
|
||||
if not inhalt.strip():
|
||||
return "Die Datei ist leer."
|
||||
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
||||
return (
|
||||
"Die Datei ist größer als "
|
||||
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
||||
)
|
||||
if inhalt.lstrip()[:1] == "<":
|
||||
return (
|
||||
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
|
||||
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
|
||||
)
|
||||
if zaehle_disc_eintraege(inhalt) == 0:
|
||||
return (
|
||||
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
|
||||
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def keydb_status(daten_dir: str = None) -> dict:
|
||||
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
|
||||
pfad = keydb_pfad(daten_dir)
|
||||
try:
|
||||
angaben = os.stat(pfad)
|
||||
except OSError:
|
||||
return {
|
||||
"vorhanden": False,
|
||||
"pfad": pfad,
|
||||
"groesse_bytes": 0,
|
||||
"eintraege": 0,
|
||||
"geaendert": "",
|
||||
}
|
||||
eintraege = 0
|
||||
try:
|
||||
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
||||
eintraege = zaehle_disc_eintraege(datei.read())
|
||||
except OSError:
|
||||
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
||||
return {
|
||||
"vorhanden": True,
|
||||
"pfad": pfad,
|
||||
"groesse_bytes": angaben.st_size,
|
||||
"eintraege": eintraege,
|
||||
"geaendert": _iso(angaben.st_mtime),
|
||||
}
|
||||
|
||||
|
||||
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
||||
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
||||
|
||||
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
||||
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
||||
"""
|
||||
pfad = keydb_pfad(daten_dir)
|
||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||
neben = pfad + ".neu"
|
||||
try:
|
||||
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
|
||||
datei.write(inhalt)
|
||||
os.replace(neben, pfad)
|
||||
except OSError:
|
||||
# Die Nebendatei nie liegen lassen: eine halb geschriebene
|
||||
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
|
||||
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
|
||||
try:
|
||||
os.remove(neben)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return keydb_status(daten_dir)
|
||||
|
||||
|
||||
def keydb_loeschen(daten_dir: str = None) -> dict:
|
||||
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
|
||||
|
||||
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
|
||||
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
|
||||
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
|
||||
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
|
||||
"""
|
||||
try:
|
||||
os.remove(keydb_pfad(daten_dir))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return keydb_status(daten_dir)
|
||||
|
||||
|
||||
def ist_aacs_dump(name: str) -> bool:
|
||||
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
|
||||
|
||||
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
|
||||
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
|
||||
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
|
||||
"""
|
||||
return (
|
||||
name.endswith(".tgz")
|
||||
and "/" not in name
|
||||
and "\\" not in name
|
||||
and not name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def dumps_auflisten(daten_dir: str = None) -> list:
|
||||
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
|
||||
ordner = daten_dir or DATEN_DIR
|
||||
try:
|
||||
namen = os.listdir(ordner)
|
||||
except OSError:
|
||||
return []
|
||||
liste = []
|
||||
for name in namen:
|
||||
if not ist_aacs_dump(name):
|
||||
continue
|
||||
try:
|
||||
angaben = os.stat(os.path.join(ordner, name))
|
||||
except OSError:
|
||||
continue
|
||||
liste.append(
|
||||
{
|
||||
"name": name,
|
||||
"groesse_bytes": angaben.st_size,
|
||||
"geaendert": _iso(angaben.st_mtime),
|
||||
"_sort": angaben.st_mtime,
|
||||
}
|
||||
)
|
||||
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
|
||||
for eintrag in liste:
|
||||
del eintrag["_sort"]
|
||||
return liste
|
||||
|
||||
|
||||
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
||||
#
|
||||
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
||||
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
||||
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
||||
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
||||
|
||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
||||
|
||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
|
||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
||||
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
||||
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def private_data_pfad(daten_dir: str = None) -> str:
|
||||
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
||||
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
||||
|
||||
|
||||
def zaehle_schluessel(rohdaten: bytes) -> int:
|
||||
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
||||
|
||||
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
||||
Installation". Ein frischer, leerer Speicher enthält nur eine
|
||||
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
||||
unbekannte UHD-Disc scheitert.
|
||||
"""
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
|
||||
except (tarfile.TarError, OSError, EOFError):
|
||||
return 0
|
||||
|
||||
|
||||
def private_data_pruefen(rohdaten: bytes) -> str:
|
||||
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
||||
|
||||
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
||||
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
||||
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
||||
niemand versteht warum).
|
||||
"""
|
||||
if not rohdaten:
|
||||
return "Die Datei ist leer."
|
||||
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
||||
return (
|
||||
"Die Datei ist größer als "
|
||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
||||
"MakeMKV-Schlüsselspeicher."
|
||||
)
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
||||
namen = archiv.getnames()
|
||||
except (tarfile.TarError, OSError, EOFError):
|
||||
return (
|
||||
"Das ist kein tar-Archiv. Erwartet wird die Datei "
|
||||
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
|
||||
)
|
||||
if not any(name.startswith("hkd_") for name in namen):
|
||||
return (
|
||||
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
||||
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
||||
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
||||
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
||||
pfad = private_data_pfad(daten_dir)
|
||||
try:
|
||||
angaben = os.stat(pfad)
|
||||
with open(pfad, "rb") as datei:
|
||||
schluessel = zaehle_schluessel(datei.read())
|
||||
except OSError:
|
||||
return {
|
||||
"vorhanden": False,
|
||||
"pfad": pfad,
|
||||
"groesse_bytes": 0,
|
||||
"schluessel": 0,
|
||||
"geaendert": "",
|
||||
}
|
||||
return {
|
||||
"vorhanden": True,
|
||||
"pfad": pfad,
|
||||
"groesse_bytes": angaben.st_size,
|
||||
"schluessel": schluessel,
|
||||
"geaendert": _iso(angaben.st_mtime),
|
||||
}
|
||||
|
||||
|
||||
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
||||
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
||||
|
||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
||||
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
||||
"""
|
||||
pfad = private_data_pfad(daten_dir)
|
||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||
neben = pfad + ".neu"
|
||||
try:
|
||||
with open(neben, "wb") as datei:
|
||||
datei.write(rohdaten)
|
||||
os.replace(neben, pfad)
|
||||
except OSError:
|
||||
try:
|
||||
os.remove(neben)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return schluesselspeicher_status(daten_dir)
|
||||
|
||||
|
||||
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
||||
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
||||
|
||||
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
||||
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
||||
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
||||
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
||||
"""
|
||||
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
||||
if key:
|
||||
zeilen.append('app_Key = "{}"'.format(key))
|
||||
text = "\n".join(zeilen).strip("\n")
|
||||
return text + "\n" if text else ""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
|
||||
|
||||
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
|
||||
t=1053) wechselt etwa monatlich und läuft zum Monatsende ab. Ohne gültigen Key
|
||||
fällt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
|
||||
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
|
||||
|
||||
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
|
||||
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
|
||||
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
|
||||
Rebuild/Neustart, ab dem nächsten Rip.
|
||||
|
||||
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
|
||||
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
|
||||
unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
|
||||
|
||||
Richtigstellung 25.07.2026: Hier stand früher, MakeMKV ziehe den AACS-Schluessel
|
||||
via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt für Blu-ray, aber
|
||||
NICHT für 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
|
||||
und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
|
||||
Messungen stehen im Modul-Kopf von makemkv_daten.py.
|
||||
|
||||
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
|
||||
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
|
||||
- Key-Format: "T-" + langer String aus [A-Za-z0-9_] (typ. ~66 Zeichen; die exakte
|
||||
Laenge variiert je Key, darum kein fixes Limit, sondern mind. 50, greedy).
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
FORUM_URL = "https://forum.makemkv.com/forum/viewtopic.php?t=1053"
|
||||
KEY_RE = re.compile(r"T-[A-Za-z0-9_]{50,}")
|
||||
_HEADERS = {"User-Agent": "rippy-makemkv-key-refresh/1.0"}
|
||||
|
||||
|
||||
def extract_key(html: str) -> str | None:
|
||||
"""Zieht den Beta-Key ("T-...") aus dem Forum-HTML. Pure Funktion -> testbar."""
|
||||
treffer = KEY_RE.search(html or "")
|
||||
return treffer.group(0) if treffer else None
|
||||
|
||||
|
||||
def fetch_current_key(timeout: int = 15) -> str | None:
|
||||
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
|
||||
import requests # lazy: hält extract_key + den Test dependency-frei
|
||||
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
|
||||
antwort.raise_for_status()
|
||||
return extract_key(antwort.text)
|
||||
|
||||
|
||||
def _apply_key(key: str) -> bool:
|
||||
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
|
||||
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
||||
import db
|
||||
einstellungen = db.get_settings()
|
||||
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
||||
return False
|
||||
einstellungen["makemkvAppKey"] = key
|
||||
db.save_settings(einstellungen)
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_once() -> bool:
|
||||
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
||||
import db
|
||||
key = await asyncio.to_thread(fetch_current_key)
|
||||
if not key:
|
||||
db.add_log("warning", "makemkv-key",
|
||||
"Aktueller Beta-Key nicht im Forum gefunden (Format geaendert?).")
|
||||
return False
|
||||
geaendert = await asyncio.to_thread(_apply_key, key)
|
||||
if geaendert:
|
||||
db.add_log("success", "makemkv-key",
|
||||
f"MakeMKV-Beta-Key automatisch erneuert (…{key[-6:]}).")
|
||||
return geaendert
|
||||
|
||||
|
||||
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
||||
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
||||
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
|
||||
import db
|
||||
await asyncio.sleep(start_verzoegerung_s)
|
||||
while True:
|
||||
try:
|
||||
await refresh_once()
|
||||
except Exception as exc:
|
||||
db.add_log("warning", "makemkv-key", f"Key-Refresh fehlgeschlagen: {exc!r}")
|
||||
await asyncio.sleep(max(3600, intervall_stunden * 3600))
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
|
||||
|
||||
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
|
||||
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
|
||||
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
|
||||
alle anderen Container (Worker sieht das Ziel sofort).
|
||||
|
||||
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
|
||||
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
|
||||
sondern über eine temporäre credentials-Datei).
|
||||
"""
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import db
|
||||
|
||||
MEDIA_ROOT = "/app/media"
|
||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
||||
|
||||
|
||||
def _mountpoint(name: str) -> str:
|
||||
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
|
||||
|
||||
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
|
||||
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
|
||||
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
|
||||
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping
|
||||
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
|
||||
Worker (v3.14), diesmal vom Test gefunden statt live.
|
||||
"""
|
||||
return posixpath.join(MEDIA_ROOT, name)
|
||||
|
||||
|
||||
def validiere_name(name: str) -> bool:
|
||||
return bool(NAME_MUSTER.match(name))
|
||||
|
||||
|
||||
def ist_gemountet(name: str) -> bool:
|
||||
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
|
||||
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
|
||||
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
|
||||
try:
|
||||
return os.path.ismount(_mountpoint(name))
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def ist_erreichbar(name: str) -> bool:
|
||||
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
|
||||
|
||||
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
|
||||
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
|
||||
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
|
||||
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
||||
)
|
||||
return ergebnis.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def pfad_lage(ziel: str) -> str:
|
||||
"""Existiert dieses Verzeichnis? „da" | „weg" | „unklar" — mit Zeitgrenze.
|
||||
|
||||
⚠️ Der Grund, warum es das gibt (Befund 26.07.2026, an Zeitstempeln gemessen):
|
||||
Eine Wiederanbindung brauchte **3 Minuten 15 Sekunden**, und die Zeit ging
|
||||
nicht in die Mount-Versuche, sondern in die allererste Zeile von `mounten()`:
|
||||
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
|
||||
`exist_ok` prüft mit `os.path.isdir()`, und ein `stat` auf einen TOTEN
|
||||
CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf,
|
||||
der nur „lege den Ordner an, falls er fehlt" bedeutet, hing also drei Minuten
|
||||
— bevor irgendeine der sorgfältig begrenzten Prüfungen überhaupt dran war.
|
||||
Dritter Fund derselben Sorte an einem Tag: os-Aufruf auf einen Netzpfad ohne
|
||||
Zeitgrenze.
|
||||
|
||||
Deshalb wird die Lage jetzt mit einem Kind-Prozess erfragt (`timeout 4 ls`),
|
||||
der sich abbrechen lässt. „unklar" heißt: existiert, antwortet aber nicht —
|
||||
dann ist ein makedirs weder nötig noch möglich.
|
||||
"""
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unklar"
|
||||
if ergebnis.returncode == 0:
|
||||
return "da"
|
||||
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||||
|
||||
|
||||
def wirklich_erreichbar(name: str, warten=None) -> bool:
|
||||
"""Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft)
|
||||
|
||||
Warum zweimal (Befund 26.07.2026): Direkt nach einem frischen `mount`
|
||||
antwortete die Freigabe reproduzierbar — und Sekunden später lief jeder
|
||||
Zugriff in die Zeitgrenze. Ursache ist ein Wettlauf beim Aufräumen:
|
||||
`_stale_mounts_loesen` benutzt `umount -l`, und das ist LAZY — es hängt den
|
||||
Mount sofort aus der Sicht aus, der eigentliche Abbau passiert später. Fällt
|
||||
dieser Abbau samt Propagation (rshared) hinter den neuen Mount, zeigt der
|
||||
Pfad wieder auf die Leiche.
|
||||
|
||||
Eine einzige Probe kann das nicht sehen. Zwei mit Abstand schon.
|
||||
"""
|
||||
if warten is None:
|
||||
import time
|
||||
|
||||
warten = time.sleep
|
||||
if not ist_erreichbar(name):
|
||||
return False
|
||||
warten(3)
|
||||
return ist_erreichbar(name)
|
||||
|
||||
|
||||
def schreibtest(pfad: str) -> bool:
|
||||
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
||||
|
||||
Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob
|
||||
die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf.
|
||||
"""
|
||||
testdatei = os.path.join(pfad, ".rippy-schreibtest")
|
||||
try:
|
||||
with open(testdatei, "w") as f:
|
||||
f.write("ok")
|
||||
os.unlink(testdatei)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _stale_mounts_loesen(ziel: str) -> int:
|
||||
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||
|
||||
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||
über viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||
Re-Mounten idempotent. `umount -l` (lazy) hängt nicht an einem toten CIFS.
|
||||
Rueckgabe: Zahl der geloesten Schichten.
|
||||
"""
|
||||
geloest = 0
|
||||
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["umount", "-l", ziel], capture_output=True, timeout=10
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
break
|
||||
if ergebnis.returncode != 0:
|
||||
break # nichts (mehr) gemountet
|
||||
geloest += 1
|
||||
if geloest:
|
||||
# ⚠️ Kurz durchatmen, BEVOR neu gemountet wird (Befund 26.07.2026).
|
||||
# `umount -l` ist lazy: Es hängt sofort aus der Sicht aus, der eigentliche
|
||||
# Abbau samt Propagation (rshared) passiert später. Wer direkt danach
|
||||
# mountet, riskiert, dass dieser Abbau HINTER dem neuen Mount landet und
|
||||
# der Pfad wieder auf die Leiche zeigt. Genau das hat die erste
|
||||
# Reparatur regelmäßig scheitern lassen — und der zweite Versuch mit
|
||||
# seinen 30 s Timeout war der Grund, warum eine Wiederanbindung rund
|
||||
# 150 Sekunden brauchte. Anderthalb Sekunden Warten sparen die.
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
return geloest
|
||||
|
||||
|
||||
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:
|
||||
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
|
||||
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
|
||||
cmd = ["smbclient", "-L", f"//{host}", "-g"]
|
||||
if username:
|
||||
cmd += ["-U", f"{username}%{passwort or ''}"]
|
||||
else:
|
||||
cmd += ["-N"]
|
||||
try:
|
||||
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 = []
|
||||
for zeile in (ergebnis.stdout or "").splitlines():
|
||||
# -g-Format: Disk|Freigabename|Kommentar
|
||||
teile = zeile.split("|")
|
||||
if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"):
|
||||
freigaben.append(teile[1])
|
||||
if not freigaben and ergebnis.returncode != 0:
|
||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
"Freigaben-Abfrage fehlgeschlagen: "
|
||||
+ uebersetze_smb_fehler(fehler, bool(username))
|
||||
)
|
||||
return freigaben
|
||||
|
||||
|
||||
def unc_aus_quelle(typ: str, quelle: str) -> str:
|
||||
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
|
||||
|
||||
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
|
||||
(optionale Funktion „Client für NFS"), die Schreibweise ist aber eine
|
||||
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen —
|
||||
raten verstößt gegen AGENTS Regel D.
|
||||
"""
|
||||
if typ != "cifs" or not quelle:
|
||||
return ""
|
||||
rest = quelle.strip()
|
||||
if not rest.startswith("//"):
|
||||
return ""
|
||||
return "\\\\" + rest[2:].replace("/", "\\")
|
||||
|
||||
|
||||
def pfad_map_vorschlag(eintraege: list) -> list:
|
||||
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
|
||||
|
||||
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
|
||||
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
|
||||
— das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
|
||||
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
|
||||
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
|
||||
|
||||
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
|
||||
eingehängt und kennt ihre Quelle (z. B. `//NAS/rippy`). Der Mountpunkt ist
|
||||
`/app/media/<name>`, und beides zusammen IST das Mapping.
|
||||
"""
|
||||
vorschlaege = []
|
||||
for eintrag in eintraege or []:
|
||||
name = (eintrag.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
typ = (eintrag.get("typ") or "").strip()
|
||||
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
|
||||
vorschlaege.append({
|
||||
"name": name,
|
||||
"typ": typ,
|
||||
"quelle": eintrag.get("quelle") or "",
|
||||
"container": _mountpoint(name),
|
||||
"unc": unc,
|
||||
"gemountet": ist_gemountet(name),
|
||||
})
|
||||
return vorschlaege
|
||||
|
||||
|
||||
def pfad_map_zeile(vorschlaege: list) -> str:
|
||||
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
|
||||
|
||||
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
|
||||
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
|
||||
weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
|
||||
beim ersten passenden Präfix aufhört.
|
||||
"""
|
||||
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
|
||||
return ";".join(paare)
|
||||
|
||||
|
||||
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
username: str = "", passwort: str = "") -> bool:
|
||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
||||
|
||||
Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei
|
||||
Mount-Fehlern.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
|
||||
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
|
||||
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
|
||||
if pfad_lage(ziel) == "weg":
|
||||
try:
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
except OSError:
|
||||
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
|
||||
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
|
||||
#
|
||||
# Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`.
|
||||
# Das hatte zwei Folgen, beide am selben Nachmittag gemessen:
|
||||
#
|
||||
# 1. Nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
|
||||
# Zugriffen liefen in 10 s Timeout, /storage-mounts meldete
|
||||
# `reachable: false`) — und blieb es. Der Mountpunkt existierte im neuen
|
||||
# Container weiter (Bind-Mount vom Host), `ismount` sagte also „ist schon
|
||||
# da" und das Wiederherstellen brach genau hier ab. Die Reparatur per
|
||||
# POST /storage-mounts/rippy/repair stellte sie in einer Sekunde her —
|
||||
# beim Start hätte dasselbe passieren müssen.
|
||||
# 2. `schreibtest()` öffnet eine Datei OHNE Zeitgrenze. Auf einem toten CIFS
|
||||
# blockiert das im Kernel (Thread-Zustand D), und `alle_remounten()`
|
||||
# hängt beim API-Start dauerhaft in einem Hintergrund-Thread.
|
||||
#
|
||||
# `ist_erreichbar()` hat eine harte Grenze (`timeout 3 ls`). Antwortet die
|
||||
# Freigabe, sind ismount und schreibtest danach gefahrlos. Antwortet sie
|
||||
# nicht, fällt es unten durch: lösen, frisch mounten. Das ist genau der Weg,
|
||||
# den `reparieren()` geht — nur eben automatisch.
|
||||
if ist_erreichbar(name):
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
except OSError:
|
||||
pass # unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||
|
||||
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||
_stale_mounts_loesen(ziel)
|
||||
|
||||
creds_datei = None
|
||||
try:
|
||||
if typ == "nfs":
|
||||
opts = optionen or "vers=4,soft,timeo=100"
|
||||
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
|
||||
elif typ == "cifs":
|
||||
teile = [optionen] if optionen else []
|
||||
if username:
|
||||
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
|
||||
creds = tempfile.NamedTemporaryFile(
|
||||
"w", delete=False, prefix="cifs-", suffix=".cred"
|
||||
)
|
||||
creds.write(f"username={username}\npassword={passwort or ''}\n")
|
||||
creds.close()
|
||||
os.chmod(creds.name, 0o600)
|
||||
creds_datei = creds.name
|
||||
teile.append(f"credentials={creds.name}")
|
||||
else:
|
||||
teile.append("guest")
|
||||
teile.append("iocharset=utf8")
|
||||
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
|
||||
else:
|
||||
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||
|
||||
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
|
||||
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
|
||||
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
|
||||
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
|
||||
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
|
||||
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
|
||||
# gelegentlich schief, ohne es zu melden.
|
||||
#
|
||||
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
|
||||
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
|
||||
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
|
||||
# „FEHLER" statt „eingehängt", was schlicht die Wahrheit ist.
|
||||
for versuch in (1, 2):
|
||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if ergebnis.returncode != 0:
|
||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||
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}")
|
||||
# Zweimal mit Abstand prüfen — eine einzige Probe direkt nach dem
|
||||
# Mount sieht den Wettlauf mit dem lazy umount nicht (siehe
|
||||
# wirklich_erreichbar).
|
||||
if wirklich_erreichbar(name):
|
||||
return schreibtest(ziel)
|
||||
if versuch == 1:
|
||||
_lazy_umount(ziel)
|
||||
raise RuntimeError(
|
||||
f"{quelle} wurde eingehängt, antwortet aber nicht dauerhaft (zwei "
|
||||
"Versuche). Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft "
|
||||
"meist ein erneutes Einhängen über Einstellungen → Speicherziele → "
|
||||
"Reparieren."
|
||||
)
|
||||
finally:
|
||||
if creds_datei:
|
||||
try:
|
||||
os.unlink(creds_datei)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _lazy_umount(ziel: str) -> None:
|
||||
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
|
||||
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
|
||||
gar nicht mehr entfernen (Befund 24.07.)."""
|
||||
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def aushaengen(name: str) -> None:
|
||||
"""Freigabe abhängen und den Mountpunkt aufräumen.
|
||||
|
||||
Ohne `os.path.ismount`/`os.rmdir` als erste Schritte: Beide `stat`en den
|
||||
Pfad, und auf einem toten CIFS-Mount blockiert das im Kernel (siehe
|
||||
pfad_lage). Ein `umount` auf einen Pfad ohne Mount kostet dagegen nichts.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
ergebnis = subprocess.run(
|
||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||
_lazy_umount(ziel)
|
||||
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
|
||||
if pfad_lage(ziel) == "da":
|
||||
try:
|
||||
os.rmdir(ziel)
|
||||
except OSError:
|
||||
pass # nicht leer oder weg — egal
|
||||
|
||||
|
||||
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
username: str = "", passwort: str = "") -> bool:
|
||||
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
||||
|
||||
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||
|
||||
`os.path.ismount` stand hier vorher als Vorbedingung — auf einem toten
|
||||
CIFS-Mount blockiert dieses `stat` im Kernel (siehe pfad_lage). Gefragt wird
|
||||
es gar nicht mehr: `umount -l` auf einen Pfad, an dem nichts hängt, kostet
|
||||
nichts und meldet nur einen Rückgabewert, den hier niemand braucht.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
_lazy_umount(ziel)
|
||||
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||
|
||||
|
||||
def alle_remounten() -> list:
|
||||
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
||||
meldungen = []
|
||||
for eintrag in db.list_mounts():
|
||||
try:
|
||||
mounten(
|
||||
eintrag["name"], eintrag["typ"], eintrag["quelle"],
|
||||
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
||||
eintrag.get("passwort") or "",
|
||||
)
|
||||
meldungen.append(f"{eintrag['name']}: eingehängt")
|
||||
except Exception as e:
|
||||
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
|
||||
return meldungen
|
||||
@@ -1,169 +0,0 @@
|
||||
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from xml.dom.minidom import getDOMImplementation
|
||||
|
||||
|
||||
class NFOGenerator:
|
||||
def __init__(self):
|
||||
self.dom_impl = getDOMImplementation()
|
||||
|
||||
def _create_element(self, doc, name: str, text: str = None) -> None:
|
||||
"""Hilfsfunktion für Element-Erstellung."""
|
||||
element = doc.createElement(name)
|
||||
if text:
|
||||
element.appendChild(doc.createTextNode(str(text)))
|
||||
return element
|
||||
|
||||
def generate_movie_nfo(self, title: str, year: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
runtime: int = 0, genres: List[str] = None,
|
||||
director: str = "", writer: str = "",
|
||||
actors: List[str] = None, studio: str = "",
|
||||
premiered: str = "", mpaa: str = "") -> str:
|
||||
"""Generiere movie.nfo für Filme."""
|
||||
doc = self.dom_impl.createDocument(None, "movie", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
root.appendChild(self._create_element(doc, "runtime", runtime))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
if director:
|
||||
root.appendChild(self._create_element(doc, "director", director))
|
||||
|
||||
if writer:
|
||||
root.appendChild(self._create_element(doc, "writer", writer))
|
||||
|
||||
if actors:
|
||||
for actor in actors:
|
||||
actor_node = doc.createElement("actor")
|
||||
actor_node.appendChild(self._create_element(doc, "name", actor))
|
||||
root.appendChild(actor_node)
|
||||
|
||||
if studio:
|
||||
root.appendChild(self._create_element(doc, "studio", studio))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
if mpaa:
|
||||
root.appendChild(self._create_element(doc, "mpaa", mpaa))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_series_nfo(self, title: str, year: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
genres: List[str] = None, studio: str = "",
|
||||
premiered: str = "") -> str:
|
||||
"""Generiere series.nfo für Serien."""
|
||||
doc = self.dom_impl.createDocument(None, "tvshow", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
if studio:
|
||||
root.appendChild(self._create_element(doc, "studio", studio))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_episode_nfo(self, title: str, season: int, episode: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
director: str = "", premiered: str = "",
|
||||
writers: List[str] = None) -> str:
|
||||
"""Generiere episode.nfo für Episoden."""
|
||||
doc = self.dom_impl.createDocument(None, "episodedetails", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "season", season))
|
||||
root.appendChild(self._create_element(doc, "episode", episode))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
|
||||
if director:
|
||||
root.appendChild(self._create_element(doc, "director", director))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
if writers:
|
||||
for writer in writers:
|
||||
root.appendChild(self._create_element(doc, "credits", writer))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_album_nfo(self, title: str, artist: str, year: int,
|
||||
genres: List[str] = None, rating: float = 0.0,
|
||||
review: str = "") -> str:
|
||||
"""Generiere album.nfo für Musikalben."""
|
||||
doc = self.dom_impl.createDocument(None, "musicalbum", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "artist", artist))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
root.appendChild(self._create_element(doc, "review", review))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def save_nfo(self, content: str, output_path: Path) -> bool:
|
||||
"""Speichere NFO-Datei."""
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"NFO save error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Beispieldaten
|
||||
if __name__ == "__main__":
|
||||
nfo_gen = NFOGenerator()
|
||||
|
||||
# movie.nfo
|
||||
movie_nfo = nfo_gen.generate_movie_nfo(
|
||||
title="Inception",
|
||||
year=2010,
|
||||
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
|
||||
rating=8.8,
|
||||
runtime=148,
|
||||
genres=["Action", "Sci-Fi", "Thriller"],
|
||||
director="Christopher Nolan",
|
||||
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
|
||||
)
|
||||
|
||||
print(movie_nfo)
|
||||
@@ -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]}"
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""In welcher Phase ist ein Job gestorben — und was hilft danach wirklich?
|
||||
|
||||
Commander-Befund 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||
gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||
eine Option anbieten."
|
||||
|
||||
Die Frage war berechtigt und der Knopf war falsch: Er hieß nur „Neu" und rief
|
||||
immer `POST /jobs/{id}/retry-transcode` — also immer die KOMPRESSION. Für einen
|
||||
abgebrochenen Transcode ist das goldrichtig (die Roh-MKV liegt vollständig da,
|
||||
man spart eine Stunde Rippen). Für einen abgebrochenen RIP ist es Unsinn: Am
|
||||
26.07.2026 lagen 5,1 GB von rund 40 GB da, und „Neu" hätte daraus brav einen
|
||||
Film komprimiert, der bei 12 % aufhört.
|
||||
|
||||
## Warum es dafür eine MARKE braucht
|
||||
|
||||
Sobald `status = "failed"` steht, ist der vorherige Zustand fort — die Spalte
|
||||
hat nur einen Wert. `zombies.war_im_rip()` im Worker kann die Phase noch sehen,
|
||||
weil sie dort im Moment des Aufräumens vorliegt; die API sieht später nur noch
|
||||
„failed". Deshalb schreibt der Worker die Phase in die Job-Metadaten:
|
||||
|
||||
* Rip-Start → `rip_fertig = false`
|
||||
* Rip fertig (Übergabe an die Kompression) → `rip_fertig = true`
|
||||
* Start eines Transcodes → `rip_fertig = true` (heilt Bestandsjobs mit)
|
||||
|
||||
## Drei Antworten, nicht zwei
|
||||
|
||||
Die Marke FEHLT bei jedem Job, der vor dieser Änderung lief. Diese Jobs
|
||||
„rip" zu nennen wäre geraten — und würde dem Commander an einem
|
||||
Bestandsjob mit 79,6 GB intakter Rohdaten das „Neu komprimieren" wegnehmen,
|
||||
das dort genau richtig ist. Wer die Phase nicht kennt, sagt das: `unklar`
|
||||
lässt das UI beide Wege anbieten, jeden mit einem ehrlichen Satz.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# Schlüssel in den Job-Metadaten. Muss identisch in worker/tasks.py stehen —
|
||||
# es gibt kein geteiltes Paket zwischen den Containern.
|
||||
RIP_FERTIG = "rip_fertig"
|
||||
|
||||
# Die drei möglichen Antworten.
|
||||
NEU_KOMPRIMIEREN = "transcode"
|
||||
NEU_RIPPEN = "rip"
|
||||
UNKLAR = "unklar"
|
||||
NICHTS = ""
|
||||
|
||||
|
||||
def _meta(meta_json) -> dict:
|
||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
||||
if isinstance(meta_json, dict):
|
||||
return meta_json
|
||||
if not meta_json:
|
||||
return {}
|
||||
try:
|
||||
gelesen = json.loads(meta_json)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return gelesen if isinstance(gelesen, dict) else {}
|
||||
|
||||
|
||||
def retry_art(job) -> str:
|
||||
"""Was soll der Knopf an DIESEM Job anbieten?
|
||||
|
||||
* `NICHTS` — der Job ist nicht fehlgeschlagen, es gibt nichts zu
|
||||
wiederholen.
|
||||
* `NEU_KOMPRIMIEREN`— der Rip war fertig, nur die Kompression starb.
|
||||
* `NEU_RIPPEN` — der Rip selbst starb; alles Rohe ist ein Bruchstück.
|
||||
* `UNKLAR` — kein Vermerk (Bestandsjob), beide Wege anbieten.
|
||||
"""
|
||||
if (job.get("status") or "") != "failed":
|
||||
return NICHTS
|
||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
||||
if marke is True:
|
||||
return NEU_KOMPRIMIEREN
|
||||
if marke is False:
|
||||
return NEU_RIPPEN
|
||||
return UNKLAR
|
||||
+278
-29
@@ -5,19 +5,20 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru
|
||||
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import detection
|
||||
from clients.tmdb import TMDBClient
|
||||
from clients.jikan import JikanClient
|
||||
from clients.musicbrainz import MusicBrainzClient
|
||||
from clients.omdb import OMDbClient
|
||||
from clients.thetvdb import TheTVDBClient
|
||||
from cache import get, set as cache_set
|
||||
from cache.keys import generate_prescan_key
|
||||
|
||||
|
||||
def normalize_disc_label(label: str) -> str:
|
||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||
|
||||
@@ -36,7 +37,8 @@ def read_iso_volume_label(device_path: str) -> Optional[str]:
|
||||
"""Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor.
|
||||
|
||||
Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für
|
||||
DVDs und die meisten Blu-rays (UDF-Bridge) — ganz ohne Zusatzwerkzeuge.
|
||||
DVDs — Blu-rays sind oft reines UDF ohne ISO-Bridge (dann greift
|
||||
read_udf_volume_label).
|
||||
"""
|
||||
try:
|
||||
with open(device_path, "rb") as f:
|
||||
@@ -50,6 +52,148 @@ def read_iso_volume_label(device_path: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_udf_dstring(data: bytes) -> str:
|
||||
"""Dekodiert einen UDF d-string (ECMA-167 1/7.2.12; pure Funktion, testbar).
|
||||
|
||||
Letztes Byte = genutzte Länge, erstes Byte = Kompressions-ID
|
||||
(8 = Latin-1, 16 = UTF-16BE), dazwischen der Text.
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
laenge = data[-1]
|
||||
if laenge < 2 or laenge > len(data) - 1:
|
||||
return ""
|
||||
comp_id, text = data[0], data[1:laenge]
|
||||
try:
|
||||
if comp_id == 8:
|
||||
return text.decode("latin-1").strip()
|
||||
if comp_id == 16:
|
||||
return text.decode("utf-16-be").strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
||||
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
|
||||
|
||||
Das Volume-Label ist oft kryptisch (BD_EVG_D2) — der echte Titel
|
||||
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
|
||||
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
|
||||
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
||||
"""
|
||||
import re as _re
|
||||
mountpoint = "/mnt/rippy-disc"
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
gemountet = False
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
return None
|
||||
gemountet = True
|
||||
|
||||
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
|
||||
if not os.path.isdir(meta_dir):
|
||||
return None
|
||||
kandidaten = sorted(os.listdir(meta_dir))
|
||||
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
|
||||
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
|
||||
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
||||
)
|
||||
if not bdmt:
|
||||
return None
|
||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
|
||||
# (Namespaces variieren je Authoring-Werkzeug)
|
||||
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
||||
if treffer:
|
||||
return treffer.group(1).strip()
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if gemountet:
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
||||
|
||||
|
||||
def titel_kandidaten(titel: str) -> List[str]:
|
||||
"""Suchvarianten für die Metadaten-APIs (progressive Degradation, ARM-Lehre).
|
||||
|
||||
'Evangelion: 2.22 You Can (Not) Advance.' → auch 'Evangelion: 2.22 …' ohne
|
||||
Zusatz, ohne Doppelpunkt-Teil usw. — Disc-Titel sind selten API-freundlich.
|
||||
"""
|
||||
kandidaten = []
|
||||
|
||||
def merke(t: str):
|
||||
t = " ".join(t.split()).strip(" .")
|
||||
if t and t not in kandidaten:
|
||||
kandidaten.append(t)
|
||||
|
||||
merke(titel)
|
||||
if ":" in titel:
|
||||
merke(titel.split(":", 1)[0]) # Haupttitel vor dem Doppelpunkt
|
||||
merke(titel.replace(":", ""))
|
||||
import re as _re
|
||||
merke(_re.sub(r"\([^)]*\)", "", titel)) # Klammer-Zusätze raus
|
||||
# Iterativ hintere Worte strippen (ARM-Lehre): "Evangelion 2.22" muss
|
||||
# auch als "Evangelion" gesucht werden — Versions-/Zusatz-Tokens stehen
|
||||
# fast immer hinten. Begrenzt, damit die API-Anfragen überschaubar bleiben.
|
||||
worte = titel.split()
|
||||
while len(worte) > 1 and len(kandidaten) < 6:
|
||||
worte = worte[:-1]
|
||||
merke(" ".join(worte))
|
||||
return kandidaten
|
||||
|
||||
|
||||
def disc_fingerprint(device_path: str) -> str:
|
||||
"""Billiger, stabiler Disc-Fingerabdruck (Volume-Label + Größe).
|
||||
|
||||
Für Cache-Keys und das Merken manueller Korrekturen — bewusst OHNE den
|
||||
teuren Mount-Titel, damit ihn jeder Endpunkt schnell berechnen kann.
|
||||
"""
|
||||
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) or "unbekannt"
|
||||
try:
|
||||
groesse = detection.disc_size_bytes(device_path)
|
||||
except OSError:
|
||||
groesse = 0
|
||||
return f"{label}|{groesse}"
|
||||
|
||||
|
||||
def read_udf_volume_label(device_path: str) -> Optional[str]:
|
||||
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
|
||||
|
||||
Weg laut ECMA-167: Anchor Volume Descriptor Pointer bei Sektor 256 →
|
||||
zeigt auf die Main Volume Descriptor Sequence → darin der PVD (Tag-ID 1)
|
||||
mit dem Volume Identifier (d-string, 32 Bytes ab Offset 24).
|
||||
"""
|
||||
sektor = 2048
|
||||
try:
|
||||
with open(device_path, "rb") as f:
|
||||
f.seek(256 * sektor)
|
||||
avdp = f.read(sektor)
|
||||
if len(avdp) < 24 or int.from_bytes(avdp[0:2], "little") != 2:
|
||||
return None
|
||||
vds_ort = int.from_bytes(avdp[20:24], "little")
|
||||
f.seek(vds_ort * sektor)
|
||||
# VDS-Deskriptoren durchgehen, bis der PVD (Tag-ID 1) kommt
|
||||
for _ in range(32):
|
||||
block = f.read(sektor)
|
||||
if len(block) < 56:
|
||||
return None
|
||||
tag_id = int.from_bytes(block[0:2], "little")
|
||||
if tag_id == 1:
|
||||
return parse_udf_dstring(block[24:56]) or None
|
||||
if tag_id == 8: # Terminating Descriptor — Sequenz zu Ende
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class PreScanResult:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -58,7 +202,8 @@ class PreScanResult:
|
||||
year: int = None,
|
||||
confidence: float = 0.0,
|
||||
metadata: Dict = None,
|
||||
tracks: List[Dict] = None
|
||||
tracks: List[Dict] = None,
|
||||
fingerprint: str = ""
|
||||
):
|
||||
self.disc_type = disc_type
|
||||
self.title = title
|
||||
@@ -66,6 +211,9 @@ class PreScanResult:
|
||||
self.confidence = confidence
|
||||
self.metadata = metadata or {}
|
||||
self.tracks = tracks or []
|
||||
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
|
||||
# Duplikat-Warnung („diese Disc wurde schon gerippt")
|
||||
self.fingerprint = fingerprint
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
@@ -74,7 +222,8 @@ class PreScanResult:
|
||||
"year": self.year,
|
||||
"confidence": self.confidence,
|
||||
"metadata": self.metadata,
|
||||
"tracks": self.tracks
|
||||
"tracks": self.tracks,
|
||||
"fingerprint": self.fingerprint
|
||||
}
|
||||
|
||||
|
||||
@@ -84,11 +233,18 @@ class PreScan:
|
||||
self.musicbrainz = MusicBrainzClient()
|
||||
self.thetvdb = TheTVDBClient()
|
||||
self.omdb = OMDbClient()
|
||||
self.jikan = JikanClient()
|
||||
|
||||
def scan(self, device_path: str) -> PreScanResult:
|
||||
"""Führe Pre-Scan durch."""
|
||||
disc_type = self._detect_disc_type(device_path)
|
||||
toc = self._read_toc(device_path, disc_type)
|
||||
# Typ + Disc-Fingerabdruck (Label|Größe) an die Zweige durchreichen —
|
||||
# der Typ ging vorher verloren (BDs hießen immer "DVD"), und der
|
||||
# Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc
|
||||
# die Metadaten der vorherigen).
|
||||
toc["disc_type"] = disc_type
|
||||
toc["fingerprint"] = disc_fingerprint(device_path)
|
||||
|
||||
if disc_type == "CD":
|
||||
return self._scan_audio(device_path, toc)
|
||||
@@ -101,7 +257,7 @@ class PreScan:
|
||||
installiert, die Erkennung fiel still immer auf "DVD" zurück.
|
||||
"""
|
||||
typ = detection.detect_disc_type(device_path)
|
||||
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD")
|
||||
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray", "uhd": "4K UHD"}.get(typ, "DVD")
|
||||
|
||||
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
||||
"""Lese TOC (Table of Contents)."""
|
||||
@@ -129,18 +285,22 @@ class PreScan:
|
||||
"duration": 0
|
||||
})
|
||||
else:
|
||||
# Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft
|
||||
# überall. (makemkvcon gibt es NUR im Worker-Container; der
|
||||
# alte Aufruf hier scheiterte in der API still und der Titel
|
||||
# blieb ewig "Unknown Title".)
|
||||
label = read_iso_volume_label(device_path)
|
||||
# Titel-Quelle 1 (beste): Klartext-Titel aus den BD-Metadaten
|
||||
# (BDMV/META — Labels wie BD_EVG_D2 taugen nicht für APIs)
|
||||
klartext = read_disc_title_via_mount(device_path)
|
||||
if klartext:
|
||||
toc["title"] = klartext
|
||||
else:
|
||||
# Titel-Quelle 2: Volume-Label direkt vom Medium.
|
||||
# ISO-9660 für DVDs, UDF für Blu-rays (keine ISO-Bridge).
|
||||
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path)
|
||||
if label:
|
||||
toc["title"] = normalize_disc_label(label)
|
||||
|
||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||
if shutil.which("makemkvcon"):
|
||||
result = subprocess.run(
|
||||
["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
|
||||
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
@@ -159,7 +319,7 @@ class PreScan:
|
||||
|
||||
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||
"""Pre-Scan für Audio-CD."""
|
||||
cache_key = generate_prescan_key(device_path, is_audio=True)
|
||||
cache_key = generate_prescan_key(device_path, is_audio=True, fingerprint=toc.get("fingerprint", ""))
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return PreScanResult(**cached)
|
||||
@@ -205,27 +365,37 @@ class PreScan:
|
||||
disc_type="CD",
|
||||
title=album,
|
||||
tracks=toc["tracks"],
|
||||
confidence=confidence
|
||||
confidence=confidence,
|
||||
fingerprint=toc.get("fingerprint", "")
|
||||
)
|
||||
cache_set(cache_key, result.to_dict())
|
||||
return result
|
||||
|
||||
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||
"""Pre-Scan für DVD/Blu-ray."""
|
||||
cache_key = generate_prescan_key(device_path, is_audio=False)
|
||||
cache_key = generate_prescan_key(device_path, is_audio=False, fingerprint=toc.get("fingerprint", ""))
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return PreScanResult(**cached)
|
||||
|
||||
title = normalize_disc_label(toc["title"] or "") or "Unknown Title"
|
||||
# Titel NICHT nochmal normalisieren: aus bdmt_*.xml kommt er sauber
|
||||
# („2.22" würde die Label-Normalisierung zu „2 22" zerlegen)
|
||||
title = (toc.get("title") or "").strip() or "Unknown Title"
|
||||
confidence = 0.0
|
||||
metadata = {}
|
||||
matched = False
|
||||
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
for movie in movies:
|
||||
if movie.get("title", "").lower() == title.lower():
|
||||
# Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten
|
||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||
kandidaten = titel_kandidaten(title) or [title]
|
||||
movies = []
|
||||
|
||||
for kandidat in kandidaten:
|
||||
treffer = self.tmdb.search_movie(kandidat)
|
||||
if treffer and not movies:
|
||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||
for movie in treffer:
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
confidence = 0.95
|
||||
movie_details = self.tmdb.get_movie_details(movie["id"])
|
||||
if movie_details:
|
||||
@@ -238,16 +408,19 @@ class PreScan:
|
||||
"poster_path": movie_details.get("poster_path", ""),
|
||||
"backdrop_path": movie_details.get("backdrop_path", ""),
|
||||
"runtime": movie_details.get("runtime", 0),
|
||||
"genres": [g["name"] for g in movie_details.get("genres", [])]
|
||||
"genres": [g["name"] for g in movie_details.get("genres", [])],
|
||||
"source": "tmdb",
|
||||
}
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
break
|
||||
|
||||
if not matched:
|
||||
tv_shows = self.tmdb.search_tv(title)
|
||||
if tv_shows:
|
||||
for kandidat in kandidaten:
|
||||
tv_shows = self.tmdb.search_tv(kandidat)
|
||||
for show in tv_shows:
|
||||
if show.get("name", "").lower() == title.lower():
|
||||
if show.get("name", "").lower() == kandidat.lower():
|
||||
confidence = 0.9
|
||||
tv_details = self.tmdb.get_tv_details(show["id"])
|
||||
if tv_details:
|
||||
@@ -259,20 +432,73 @@ class PreScan:
|
||||
"overview": tv_details.get("overview", ""),
|
||||
"poster_path": tv_details.get("poster_path", ""),
|
||||
"backdrop_path": tv_details.get("backdrop_path", ""),
|
||||
"genres": [g["name"] for g in tv_details.get("genres", [])]
|
||||
"genres": [g["name"] for g in tv_details.get("genres", [])],
|
||||
"source": "tmdb",
|
||||
}
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
break
|
||||
|
||||
# Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||
#
|
||||
# NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der
|
||||
# Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55
|
||||
# sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran.
|
||||
# Das ist zu großzügig, und zwar messbar (offline gerechnet mit
|
||||
# titel_aehnlichkeit, echte MyAnimeList-Titel):
|
||||
#
|
||||
# „Alien" vs. „Alien 9" → 0,83 TRIFFT
|
||||
# „Inception" vs. „Deception" → 0,78 TRIFFT
|
||||
# „Hero" vs. „Heroman" → 0,73 TRIFFT
|
||||
# „The Dark Knight" vs. „Dark Knight Rises" → 0,69 TRIFFT
|
||||
#
|
||||
# Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten — und
|
||||
# OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das
|
||||
# widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu
|
||||
# niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist
|
||||
# es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can
|
||||
# (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann
|
||||
# beides nicht leisten.
|
||||
#
|
||||
# Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer
|
||||
# Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts
|
||||
# hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug.
|
||||
jikan_unscharf = None
|
||||
if not matched:
|
||||
for kandidat in kandidaten:
|
||||
jikan_treffer = self.jikan.lookup(kandidat)
|
||||
if not jikan_treffer:
|
||||
continue
|
||||
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
|
||||
confidence = 0.85
|
||||
metadata = jikan_treffer
|
||||
matched = True
|
||||
else:
|
||||
jikan_unscharf = jikan_treffer
|
||||
break
|
||||
|
||||
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||
if not matched:
|
||||
omdb_treffer = self.omdb.lookup(title)
|
||||
for kandidat in kandidaten:
|
||||
omdb_treffer = self.omdb.lookup(kandidat)
|
||||
if omdb_treffer:
|
||||
confidence = 0.8
|
||||
metadata = omdb_treffer
|
||||
title = omdb_treffer.get("title", title)
|
||||
matched = True
|
||||
break
|
||||
|
||||
# Fallback 2b: der unscharfe Jikan-Treffer — besser als nichts, aber
|
||||
# ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein
|
||||
# TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an
|
||||
# und der Nutzer kann korrigieren).
|
||||
if not matched and jikan_unscharf:
|
||||
confidence = 0.6
|
||||
metadata = jikan_unscharf
|
||||
matched = True
|
||||
|
||||
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
||||
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
||||
@@ -289,7 +515,8 @@ class PreScan:
|
||||
"poster_path": vorschlag.get("poster_path", ""),
|
||||
"backdrop_path": vorschlag.get("backdrop_path", ""),
|
||||
"runtime": vorschlag.get("runtime", 0),
|
||||
"genres": [g["name"] for g in vorschlag.get("genres", [])]
|
||||
"genres": [g["name"] for g in vorschlag.get("genres", [])],
|
||||
"source": "tmdb",
|
||||
}
|
||||
matched = True
|
||||
|
||||
@@ -301,13 +528,35 @@ class PreScan:
|
||||
"year": None
|
||||
}
|
||||
|
||||
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
|
||||
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
|
||||
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
|
||||
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
|
||||
deutsch = self.tmdb.find_by_imdb(metadata["id"])
|
||||
if deutsch:
|
||||
for feld in ("title", "overview", "poster_path", "year", "type"):
|
||||
if deutsch.get(feld):
|
||||
metadata[feld] = deutsch[feld]
|
||||
|
||||
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
|
||||
if matched and metadata.get("title"):
|
||||
title = metadata["title"]
|
||||
|
||||
result = PreScanResult(
|
||||
disc_type=toc.get("disc_type", "DVD"),
|
||||
title=title,
|
||||
year=metadata.get("year"),
|
||||
confidence=confidence,
|
||||
metadata=metadata,
|
||||
tracks=toc.get("tracks", [])
|
||||
tracks=toc.get("tracks", []),
|
||||
fingerprint=toc.get("fingerprint", "")
|
||||
)
|
||||
cache_set(cache_key, result.to_dict())
|
||||
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
|
||||
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
|
||||
# Mittelgute Treffer verfallen nach einem Tag — falls eine bessere
|
||||
# Quelle (Jikan-504 vorbei, TMDB-Key neu) später mehr weiß.
|
||||
if confidence >= 0.9:
|
||||
cache_set(cache_key, result.to_dict(), expire=7 * 86400)
|
||||
elif confidence >= 0.6:
|
||||
cache_set(cache_key, result.to_dict(), expire=86400)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Welches HandBrake-Preset passt zu welcher Disc — und was ist das BESTE.
|
||||
|
||||
Commander-Anforderung (26.07.2026): *„Bei den Presets soll IMMER das Beste
|
||||
ausgewählt werden"* und *„wenn der Worker AV1 oder noch besseres kann, immer
|
||||
diesem empfehlen"*.
|
||||
|
||||
## Warum das hier liegt und nicht im UI
|
||||
|
||||
Die Preset-Namen standen bis dahin fest verdrahtet im UI (drei Listen in
|
||||
Settings.tsx, eine vierte im Wizard). Zwei Probleme: die Namen unterscheiden
|
||||
sich zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||
scheitern — das Projekt hat genau das zweimal teuer bezahlt (AGENTS Regel D).
|
||||
Jetzt meldet jeder Worker seine echte Liste (`worker/caps.py`,
|
||||
`HandBrakeCLI --preset-list`), und die Auswahl entsteht hier: als reine
|
||||
Funktionen, die die CI-Ampel prüft. Das UI zeigt nur noch an.
|
||||
|
||||
## Es wird nicht bewertet, sondern gestaffelt
|
||||
|
||||
Kein Punktesystem. Für jede Lage gibt es eine feste Reihenfolge von Namen, und
|
||||
genommen wird der erste, den der Worker WIRKLICH kennt. Jeder Name unten ist am
|
||||
26.07.2026 aus `--preset-list` des Worker-Images abgenommen (HandBrake 1.6.1).
|
||||
Ein Punktesystem hätte über Namen geurteilt, die es vielleicht nicht gibt.
|
||||
|
||||
## Zwei Quellen, zwei Fragen
|
||||
|
||||
- `--preset-list` sagt, welche Presets es GIBT. Die Kategorie `Hardware/` steht
|
||||
dort auch auf Maschinen ohne Hardware-Encoder.
|
||||
- `--help` sagt, welche Encoder LAUFEN.
|
||||
|
||||
Deshalb wird ein Hardware-Preset nur empfohlen, wenn ein Worker die passende
|
||||
Familie wirklich meldet. (Richtigstellung zum SAVEPOINT v3.16: dort galt es als
|
||||
unmöglich, die Hardware-Preset-Namen auf der VM zu ermitteln — die Messung sagt
|
||||
das Gegenteil, siehe caps.parse_preset_liste.)
|
||||
"""
|
||||
|
||||
# Reservewert: „diesen Disc-Typ NICHT komprimieren". Gleichlautend in
|
||||
# worker/ripping.PRESET_KEINE und ui/src/lib/encoder.ts — es gibt kein
|
||||
# geteiltes Paket zwischen den drei Seiten.
|
||||
PRESET_KEINE = "keine"
|
||||
|
||||
# Auflösungs-Token je Disc-Typ, so wie HandBrake sie in die Preset-Namen
|
||||
# schreibt. Damit bietet die 4K-Auswahl keine 1080p-Presets als Normalfall an —
|
||||
# genau dieser Griff hat in v3.12 eine 4K-UHD auf 1080p heruntergerechnet.
|
||||
AUFLOESUNG_JE_TYP = {
|
||||
"uhd": ("2160p",),
|
||||
"bluray": ("1080p",),
|
||||
"dvd": ("576p", "480p"),
|
||||
}
|
||||
|
||||
# Hardware-Familie im ENCODER-Namen (aus `--help`) → Kürzel im PRESET-Namen.
|
||||
# Gemessen: die Presets heißen „H.265 VCN 2160p 4K", der Encoder dazu aber
|
||||
# `vce_h265`. Ohne diese Zuordnung bekäme eine AMD-Karte kein AMD-Preset.
|
||||
# Reihenfolge = Vorzug, wenn eine Maschine mehrere Familien meldet.
|
||||
HW_PRESET_KUERZEL = (
|
||||
("qsv", "QSV"), # Intel QuickSync — in 1.6.1 die einzige mit AV1-Preset
|
||||
("nvenc", "NVENC"), # NVIDIA
|
||||
("vce", "VCN"), # AMD (Encoder heißt vce, Preset heißt VCN)
|
||||
)
|
||||
|
||||
# Absichtlich NICHT dabei:
|
||||
# - `vaapi`: HandBrake 1.6.1 liefert kein VAAPI-Preset mit (Kategorie
|
||||
# `Hardware/` kennt nur QSV/NVENC/VCN/MF). Ein VAAPI-Worker fällt daher auf
|
||||
# Software zurück, statt einen Namen zu bekommen, den es nicht gibt.
|
||||
# - `MF` (Media Foundation): die Presets existieren, aber ob diese Maschine sie
|
||||
# nutzen kann, ist aus der Encoder-Liste nicht ablesbar. Empfohlen wird MF
|
||||
# deshalb nie; in der Auswahlliste steht es trotzdem.
|
||||
|
||||
# Vektorbefehls-Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||
# Gleichlautend in ui/src/lib/encoder.ts (SIMD_SCHNELL).
|
||||
SIMD_SCHNELL = ("avx512f", "avx2")
|
||||
|
||||
# Rückfall, wenn KEIN Worker eine Preset-Liste meldet (alter Worker-Stand, oder
|
||||
# gar kein Worker online). Alle vier Namen sind im Worker-Image gegengeprüft.
|
||||
RUECKFALL_PRESETS = (
|
||||
"H.265 MKV 2160p60 4K",
|
||||
"H.265 MKV 1080p30",
|
||||
"H.265 MKV 576p25",
|
||||
"H.265 MKV 480p30",
|
||||
"HQ 2160p60 4K HEVC Surround",
|
||||
"HQ 1080p30 Surround",
|
||||
"HQ 576p25 Surround",
|
||||
"Super HQ 2160p60 4K HEVC Surround",
|
||||
"Super HQ 1080p30 Surround",
|
||||
)
|
||||
|
||||
|
||||
def verfuegbare_presets(workers) -> list:
|
||||
"""Alle Preset-Namen, die die gemeldeten Worker kennen (Vereinigung).
|
||||
|
||||
Vereinigung und nicht Schnittmenge: Die Kompression kann gezielt an EINEN
|
||||
Worker geroutet werden (worker_direct), es muss also nicht jeder alles
|
||||
können. Ob der gewählte Worker das Preset kennt, entscheidet er selbst —
|
||||
und meldet es als Klartext-Fehler.
|
||||
"""
|
||||
gefunden = []
|
||||
for w in workers or []:
|
||||
for name in ((w.get("info") or {}).get("presets") or []):
|
||||
if name and name not in gefunden:
|
||||
gefunden.append(name)
|
||||
return gefunden or list(RUECKFALL_PRESETS)
|
||||
|
||||
|
||||
def hardware_kuerzel(workers) -> list:
|
||||
"""Preset-Kürzel der Hardware-Encoder, die WIRKLICH gemeldet sind."""
|
||||
vorhanden = set()
|
||||
for w in workers or []:
|
||||
for encoder in (w.get("encoders") or []):
|
||||
vorhanden.add(str(encoder).lower())
|
||||
kuerzel = []
|
||||
for familie, kurz in HW_PRESET_KUERZEL:
|
||||
if any(e == familie or e.startswith(familie + "-") for e in vorhanden):
|
||||
kuerzel.append(kurz)
|
||||
return kuerzel
|
||||
|
||||
|
||||
def simd_schnell(workers) -> bool:
|
||||
"""Kann mindestens ein Worker Software-Encoding brauchbar schnell?
|
||||
|
||||
Bei unbekannter Stufe wird NICHT geraten (ein Windows-Worker ohne die
|
||||
Erkennung aus v3.16 meldet `unbekannt`) — dann zählt er hier nicht mit,
|
||||
bremst aber auch niemanden aus.
|
||||
"""
|
||||
for w in workers or []:
|
||||
if ((w.get("info") or {}).get("cpu_simd") or "") in SIMD_SCHNELL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _kandidaten(disc_type: str, kuerzel: list, schnell: bool) -> list:
|
||||
"""Namens-Staffel für diese Lage — bester Kandidat zuerst.
|
||||
|
||||
Die Software-Zweige sind bewusst dieselben, die der Wizard seit v3.15
|
||||
vorschlägt (dort schon geprüft): schwache CPU → 4K verlustfrei behalten und
|
||||
H.264 für den Rest, starke CPU → H.265 durchgehend. Neu ist nur, dass
|
||||
Hardware-Presets davor kommen.
|
||||
"""
|
||||
liste = []
|
||||
# 1. Hardware zuerst — sie ist um Größenordnungen schneller. Auf der
|
||||
# Rippy-VM brauchte Software-4K gemessene 28-55 Stunden pro Film.
|
||||
for kurz in kuerzel:
|
||||
if disc_type == "uhd":
|
||||
liste += [f"AV1 {kurz} 2160p 4K", f"H.265 {kurz} 2160p 4K"]
|
||||
elif disc_type == "bluray":
|
||||
liste += [f"H.265 {kurz} 1080p"]
|
||||
# Für DVD-Auflösungen liefert HandBrake 1.6.1 keine Hardware-Presets —
|
||||
# eine DVD ist auch in Software in Minuten fertig.
|
||||
|
||||
# 2. Software.
|
||||
if disc_type == "uhd":
|
||||
liste += ["H.265 MKV 2160p60 4K"] if schnell else [PRESET_KEINE]
|
||||
elif disc_type == "bluray":
|
||||
liste += ["H.265 MKV 1080p30"] if schnell else ["HQ 1080p30 Surround"]
|
||||
elif disc_type == "dvd":
|
||||
liste += ["H.265 MKV 576p25"] if schnell else ["HQ 576p25 Surround"]
|
||||
return liste
|
||||
|
||||
|
||||
def _grund(preset: str, disc_type: str, kuerzel: list, schnell: bool) -> str:
|
||||
"""Ein Satz, WARUM das die Empfehlung ist — der Commander liest das."""
|
||||
if preset == PRESET_KEINE:
|
||||
return (
|
||||
"Keiner der gemeldeten Worker hat AVX2 oder einen Hardware-Encoder. "
|
||||
"4K in Software dauert auf so einer Maschine gemessene 28-55 Stunden "
|
||||
"pro Film — die verlustfreie Datei zu behalten ist hier die ehrliche "
|
||||
"Wahl (kostet 20-100 GB)."
|
||||
)
|
||||
if any(f" {k} " in f" {preset} " for k in kuerzel):
|
||||
return (
|
||||
f"Nutzt den gemeldeten Hardware-Encoder ({', '.join(kuerzel)}) — "
|
||||
"um Größenordnungen schneller als die CPU. Software-x265 wäre bei "
|
||||
"gleicher Dateigröße etwas sauberer, dauert aber Stunden bis Tage."
|
||||
)
|
||||
if schnell:
|
||||
return (
|
||||
"H.265 in Software: kleinste Dateien bei sehr guter Qualität. Ein "
|
||||
"Worker mit AVX2 ist dafür schnell genug (gemessen)."
|
||||
)
|
||||
return (
|
||||
"H.264 statt H.265: ohne AVX2 ist H.265 sehr langsam. Die Datei wird "
|
||||
"etwas größer, der Encode dafür um ein Mehrfaches schneller."
|
||||
)
|
||||
|
||||
|
||||
def empfehlung(disc_type: str, workers) -> dict:
|
||||
"""Bestes Preset für diesen Disc-Typ — nur Namen, die es wirklich gibt.
|
||||
|
||||
Rückgabe: {"preset": str, "grund": str, "gefunden": bool}. `gefunden` ist
|
||||
False, wenn keiner der bekannten Namen in der Liste des Workers steht (z. B.
|
||||
eine HandBrake-Version mit anderer Benennung) — dann muss der Nutzer selbst
|
||||
wählen, und das UI sagt es ihm, statt still etwas Falsches einzustellen.
|
||||
"""
|
||||
vorhanden = verfuegbare_presets(workers)
|
||||
kuerzel = hardware_kuerzel(workers)
|
||||
schnell = simd_schnell(workers)
|
||||
for kandidat in _kandidaten(disc_type, kuerzel, schnell):
|
||||
if kandidat == PRESET_KEINE or kandidat in vorhanden:
|
||||
return {
|
||||
"preset": kandidat,
|
||||
"grund": _grund(kandidat, disc_type, kuerzel, schnell),
|
||||
"gefunden": True,
|
||||
}
|
||||
return {
|
||||
"preset": "",
|
||||
"grund": (
|
||||
"Dieses HandBrake kennt keines der Presets, die Rippy vorschlagen "
|
||||
"kann — die Namen unterscheiden sich zwischen HandBrake-Versionen. "
|
||||
"Bitte unten selbst eines aus der Liste des Workers wählen."
|
||||
),
|
||||
"gefunden": False,
|
||||
}
|
||||
|
||||
|
||||
def auswahl(disc_type: str, workers) -> list:
|
||||
"""Die Presets, die für diesen Disc-Typ zur Wahl stehen — sortiert.
|
||||
|
||||
Gefiltert auf die passende Auflösung: Bei 4K sollen keine 1080p-Presets als
|
||||
Normalfall in der Liste stehen, sonst passiert wieder, was in v3.12 passiert
|
||||
ist (4K-Rip auf 1080p heruntergerechnet, weil ein globales Preset galt).
|
||||
Das bewusste Verkleinern bleibt möglich — es steht am Ende, mit Hinweis.
|
||||
"""
|
||||
vorhanden = verfuegbare_presets(workers)
|
||||
tokens = AUFLOESUNG_JE_TYP.get(disc_type, ())
|
||||
passend = [p for p in vorhanden if any(t in p for t in tokens)]
|
||||
liste = [{"name": p, "verkleinert": False} for p in sorted(passend)]
|
||||
if disc_type == "uhd":
|
||||
# Bewusstes Verkleinern auf 1080p — eigene Gruppe, damit niemand aus
|
||||
# Versehen dort landet.
|
||||
kleiner = [p for p in vorhanden if "1080p" in p]
|
||||
liste += [{"name": p, "verkleinert": True} for p in sorted(kleiner)]
|
||||
return liste
|
||||
|
||||
|
||||
def uebersicht(workers) -> dict:
|
||||
"""Alles, was das UI für die Preset-Auswahl braucht — in einem Aufruf."""
|
||||
typen = ("uhd", "bluray", "dvd")
|
||||
return {
|
||||
"quelle": "worker" if verfuegbare_presets(workers) != list(RUECKFALL_PRESETS)
|
||||
else "rueckfall",
|
||||
"hardware": hardware_kuerzel(workers),
|
||||
"simd_schnell": simd_schnell(workers),
|
||||
"typen": {
|
||||
t: {"empfehlung": empfehlung(t, workers), "auswahl": auswahl(t, workers)}
|
||||
for t in typen
|
||||
},
|
||||
}
|
||||
+93
-43
@@ -1,17 +1,105 @@
|
||||
"""Rate-Limiting-Modul für Rippy API."""
|
||||
"""Rate-Limiting-Modul für Rippy API (pro Client-IP).
|
||||
|
||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||
|
||||
## Was diese Bremse ist — und was nicht
|
||||
|
||||
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
|
||||
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
|
||||
|
||||
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
|
||||
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die
|
||||
Folgen waren dem Commander als „wird oft neu geladen" aufgefallen.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict
|
||||
|
||||
# Default Rate Limit
|
||||
MAX_REQUESTS_PER_MINUTE = 100
|
||||
# Wie viele Anfragen pro Minute und Client durchgehen.
|
||||
#
|
||||
# ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard
|
||||
# braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI:
|
||||
#
|
||||
# Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min
|
||||
# Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min
|
||||
# Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min
|
||||
# Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker
|
||||
# ─────────
|
||||
# EIN offener Tab plus ein Worker 123/min
|
||||
#
|
||||
# Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede
|
||||
# vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt
|
||||
# keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs
|
||||
# hätten es verdoppelt.
|
||||
#
|
||||
# 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während
|
||||
# eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird.
|
||||
MAX_REQUESTS_PER_MINUTE = 600
|
||||
|
||||
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
||||
rate_limit_store: Dict[str, list] = defaultdict(list)
|
||||
|
||||
|
||||
def _ist_privat(adresse: str) -> bool:
|
||||
"""Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(adresse)
|
||||
except ValueError:
|
||||
return False
|
||||
return ip.is_private or ip.is_loopback
|
||||
|
||||
|
||||
def client_kennung(peer: str, weitergegeben: str = None) -> str:
|
||||
"""Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar)
|
||||
|
||||
`peer` ist der direkte Absender, `weitergegeben` der Inhalt von
|
||||
`X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf).
|
||||
|
||||
Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über
|
||||
den nginx — für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein
|
||||
zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard,
|
||||
obwohl es drei unabhängige Clients sind.
|
||||
|
||||
Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten
|
||||
Netz kommt — also unser eigener Proxy. Das ist keine Härtung gegen
|
||||
Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass
|
||||
eine beliebige Kopfzeile die Bremse aushebelt.
|
||||
"""
|
||||
peer = (peer or "").strip()
|
||||
kandidat = (weitergegeben or "").split(",")[0].strip()
|
||||
if kandidat and _ist_privat(peer) and _ist_privat(kandidat):
|
||||
return kandidat
|
||||
return peer or "unbekannt"
|
||||
|
||||
|
||||
# Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben?
|
||||
_letzte_meldung: Dict[str, float] = {}
|
||||
|
||||
# Abstand zwischen zwei Meldungen pro Client.
|
||||
MELDE_ABSTAND_SEKUNDEN = 60
|
||||
|
||||
|
||||
def darf_melden(client_id: str, jetzt: float = None) -> bool:
|
||||
"""Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute.
|
||||
|
||||
Ein 429 war bisher völlig unsichtbar — das UI verbuchte ihn als „nichts
|
||||
da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu
|
||||
protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die
|
||||
Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das
|
||||
Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute.
|
||||
"""
|
||||
if jetzt is None:
|
||||
jetzt = time.time()
|
||||
vorher = _letzte_meldung.get(client_id, 0.0)
|
||||
if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN:
|
||||
return False
|
||||
_letzte_meldung[client_id] = jetzt
|
||||
return True
|
||||
|
||||
|
||||
def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool:
|
||||
"""Prüfe ob Client rate-limited ist."""
|
||||
current_time = time.time()
|
||||
@@ -48,41 +136,3 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
||||
def reset_rate_limit(client_id: str) -> None:
|
||||
"""Setze Rate Limit für Client zurück."""
|
||||
rate_limit_store[client_id] = []
|
||||
|
||||
|
||||
# API-Key Store (in Produktion mit Datenbank)
|
||||
api_keys: Dict[str, Dict] = {
|
||||
"example_key": {
|
||||
"key": "example_key",
|
||||
"name": "Beispiel API Key",
|
||||
"created_at": time.time(),
|
||||
"rate_limit": 100
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def validate_api_key(api_key: str) -> Optional[Dict]:
|
||||
"""Validiere API Key."""
|
||||
if api_key in api_keys:
|
||||
return api_keys[api_key]
|
||||
return None
|
||||
|
||||
|
||||
def create_api_key(name: str) -> Dict:
|
||||
"""Erstelle neuer API Key."""
|
||||
key = secrets.token_urlsafe(32)
|
||||
api_keys[key] = {
|
||||
"key": key,
|
||||
"name": name,
|
||||
"created_at": time.time(),
|
||||
"rate_limit": MAX_REQUESTS_PER_MINUTE
|
||||
}
|
||||
return api_keys[key]
|
||||
|
||||
|
||||
def delete_api_key(key: str) -> bool:
|
||||
"""Lösche API Key."""
|
||||
if key in api_keys:
|
||||
del api_keys[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -7,7 +7,7 @@ redis==5.0.4
|
||||
requests==2.32.3
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.2
|
||||
passlib==1.7.4
|
||||
PyJWT==2.9.0
|
||||
python-multipart==0.0.9
|
||||
# passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
|
||||
# Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
|
||||
# die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
|
||||
aiofiles==24.1.0
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
|
||||
|
||||
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
|
||||
|
||||
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
|
||||
Rohschnitt (`/app/media/rippy/95afdc89-…/title_t00.mkv`). Der SAVEPOINT v3.16
|
||||
schrieb dazu: „→ ‚Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
|
||||
laufenden Instanz sagt: **`can_retry` war `false`** — den Knopf gab es gar nicht.
|
||||
|
||||
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten — im
|
||||
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
|
||||
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
|
||||
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
|
||||
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
|
||||
|
||||
workDir (Einstellung) = "" → geprüft wurde nur /app/temp/raw
|
||||
tatsächlicher Ort = /app/media/rippy/<id>
|
||||
Ergebnis → 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
|
||||
|
||||
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
|
||||
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
|
||||
damals gerippt wurde) geschlossen, statt nachzusehen.
|
||||
|
||||
## Warum gesucht und nicht gespeichert wird
|
||||
|
||||
Den Ort in die Job-Zeile zu schreiben wäre sauberer — aber die jobs-Tabelle
|
||||
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
|
||||
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
|
||||
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
|
||||
hätten den Wert ohnehin nicht.
|
||||
|
||||
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
|
||||
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
|
||||
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
|
||||
anzusehen — die oberste Ebene von `/app/media`, ohne Rekursion.
|
||||
|
||||
Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||
"""
|
||||
|
||||
import posixpath
|
||||
import subprocess
|
||||
|
||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||||
RAW_STANDARD = "/app/temp/raw"
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||
|
||||
|
||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||
|
||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||
(Ablageziele inkl. eingehängter Freigaben) — die Liste kommt vom Aufrufer,
|
||||
damit diese Funktion ohne Dateisystem testbar bleibt.
|
||||
|
||||
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
|
||||
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
|
||||
|
||||
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
|
||||
Windows Backslashes daraus, und dann greift keine Prüfung mehr — dieselbe
|
||||
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
|
||||
"""
|
||||
if not job_id:
|
||||
return []
|
||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||||
wahl = (work_dir or "").strip().rstrip("/")
|
||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||||
orte.append(posixpath.join(wahl, job_id))
|
||||
for name in media_unterordner or []:
|
||||
if name:
|
||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
||||
gesehen, eindeutig = set(), []
|
||||
for ort in orte:
|
||||
if ort not in gesehen:
|
||||
gesehen.add(ort)
|
||||
eindeutig.append(ort)
|
||||
return eindeutig
|
||||
|
||||
|
||||
def pruefen(pfad: str, laufen=None) -> str:
|
||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||
|
||||
Drei Antworten statt zwei, weil „ich konnte nicht nachsehen" etwas anderes
|
||||
ist als „es ist nicht da". Gemessen am 26.07.2026: Nach einem
|
||||
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
|
||||
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
|
||||
0,01 s — zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
|
||||
diesem Fenster der Knopf „Neu komprimieren", und der Nutzer schließt daraus,
|
||||
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
|
||||
zweimal bezahlt.
|
||||
|
||||
Begründung der Technik siehe verzeichnis_da.
|
||||
"""
|
||||
if not pfad:
|
||||
return "weg"
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
ergebnis = starten(
|
||||
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
|
||||
capture_output=True,
|
||||
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unklar"
|
||||
if ergebnis.returncode == 0:
|
||||
return "da"
|
||||
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
|
||||
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
|
||||
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||||
|
||||
|
||||
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
||||
|
||||
## Warum nicht os.path.isdir
|
||||
|
||||
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
|
||||
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
|
||||
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
|
||||
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
|
||||
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
|
||||
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
|
||||
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
|
||||
Zustand D.
|
||||
|
||||
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
|
||||
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
|
||||
weiteren Thread verbrannt, bis der Pool leer ist.
|
||||
|
||||
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
|
||||
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
|
||||
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
|
||||
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
|
||||
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
||||
|
||||
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
||||
|
||||
Für den Fall „konnte nicht nachsehen" gibt es `pruefen()` mit drei
|
||||
Antworten. Hier gilt nur „da" als ja — wer eine Ja/Nein-Antwort braucht,
|
||||
soll im Zweifel Nein bekommen.
|
||||
"""
|
||||
return pruefen(pfad, laufen) == "da"
|
||||
|
||||
|
||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
"""Die Orte, an denen wirklich etwas liegt.
|
||||
|
||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
|
||||
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
|
||||
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
|
||||
|
||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||
"""
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden = []
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
try:
|
||||
if isdir(ort):
|
||||
gefunden.append(ort)
|
||||
except OSError:
|
||||
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
|
||||
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
|
||||
continue
|
||||
return gefunden
|
||||
|
||||
|
||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||
|
||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||
Ort hat nicht geantwortet — ein leeres `pfade` ist dann kein Beweis für
|
||||
„nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
|
||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||
"""
|
||||
pruefe = pruefer or pruefen
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden, unklar = [], False
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
antwort = pruefe(ort)
|
||||
if antwort == "da":
|
||||
gefunden.append(ort)
|
||||
elif antwort == "unklar":
|
||||
unklar = True
|
||||
return {"pfade": gefunden, "unklar": unklar}
|
||||
|
||||
|
||||
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||||
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
||||
|
||||
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
|
||||
Job-Verzeichnis, Unterordner entstehen dort nicht.
|
||||
"""
|
||||
bytes_gesamt, dateien = 0, 0
|
||||
for pfad in pfade or []:
|
||||
try:
|
||||
namen = listdir(pfad)
|
||||
except OSError:
|
||||
continue
|
||||
for name in namen:
|
||||
voll = posixpath.join(pfad, name)
|
||||
try:
|
||||
if isfile(voll):
|
||||
bytes_gesamt += getsize(voll)
|
||||
dateien += 1
|
||||
except OSError:
|
||||
continue
|
||||
return bytes_gesamt, dateien
|
||||
@@ -18,10 +18,31 @@ def test_main_importierbar_und_routen_verdrahtet():
|
||||
from main import app
|
||||
|
||||
routen = {route.path for route in app.routes}
|
||||
for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"):
|
||||
for pfad in (
|
||||
"/health", "/jobs", "/devices", "/logs", "/settings",
|
||||
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
||||
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
||||
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
||||
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
||||
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||
"/system/keystore",
|
||||
):
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
|
||||
def test_dateiname_validierung_blockt_pfad_tricks():
|
||||
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
|
||||
from main import _sicherer_dateiname
|
||||
|
||||
assert _sicherer_dateiname("film.mkv") is True
|
||||
assert _sicherer_dateiname("../../etc/passwd") is False
|
||||
assert _sicherer_dateiname("a/b.mkv") is False
|
||||
assert _sicherer_dateiname("a\\b.mkv") is False
|
||||
assert _sicherer_dateiname(".versteckt") is False
|
||||
assert _sicherer_dateiname("") is False
|
||||
|
||||
|
||||
def test_worker_task_name_passt_zum_celery_client():
|
||||
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
||||
import inspect
|
||||
@@ -30,3 +51,198 @@ def test_worker_task_name_passt_zum_celery_client():
|
||||
|
||||
quelle = inspect.getsource(celery_client.start_rip)
|
||||
assert '"worker.tasks.rip_disc"' in quelle
|
||||
|
||||
|
||||
def test_remount_blockiert_den_api_start_nicht():
|
||||
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
||||
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
||||
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.startup_event)
|
||||
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
||||
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||
|
||||
|
||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
"""Befund 25.07.2026: In main.py prüften neun Stellen mit nacktem
|
||||
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
|
||||
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
|
||||
aber außerhalb. Zwilling von tasks.unter_wurzel im Worker."""
|
||||
from main import unter_wurzel
|
||||
|
||||
assert unter_wurzel("/app/media", "/app/media") is True
|
||||
assert unter_wurzel("/app/media/movies", "/app/media") is True
|
||||
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
|
||||
assert unter_wurzel("/app/mediaX", "/app/media") is False
|
||||
assert unter_wurzel("/etc/passwd", "/app/media") is False
|
||||
assert unter_wurzel("", "/app/media") is False
|
||||
assert unter_wurzel("/app/media", "") is False
|
||||
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||
|
||||
|
||||
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
|
||||
"""Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der
|
||||
Celery-Ping sammelt Antworten bis zum Timeout und kann nicht früher
|
||||
aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite
|
||||
eine Sekunde, während alle anderen Endpunkte unter 25 ms lagen.
|
||||
|
||||
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
|
||||
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
|
||||
"""
|
||||
import time as _t
|
||||
|
||||
import main
|
||||
|
||||
pings = []
|
||||
monkeypatch.setattr(main, "_ping_jetzt", lambda: pings.append(1) or ["celery@neu"])
|
||||
|
||||
# Frischer Vorrat -> ablesen, kein Ping
|
||||
monkeypatch.setitem(main._PING, "knoten", ["celery@alt"])
|
||||
monkeypatch.setitem(main._PING, "stand", _t.monotonic())
|
||||
assert main._ping_knoten() == ["celery@alt"]
|
||||
assert pings == [], "bei frischem Vorrat darf NICHT gepingt werden"
|
||||
|
||||
# Zu alter Vorrat -> einmal synchron pingen
|
||||
monkeypatch.setitem(main._PING, "stand", _t.monotonic() - main.PING_ALTER_MAX_SEKUNDEN - 1)
|
||||
assert main._ping_knoten() == ["celery@neu"]
|
||||
assert len(pings) == 1
|
||||
|
||||
# Kalter Start (nie gepingt) -> ebenfalls pingen, nicht "alles offline" melden
|
||||
monkeypatch.setitem(main._PING, "stand", -1e9)
|
||||
main._ping_knoten()
|
||||
assert len(pings) == 2
|
||||
|
||||
|
||||
def test_ping_takt_ist_kuerzer_als_die_haltbarkeit():
|
||||
"""Sonst läuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der
|
||||
Endpunkt pingt doch wieder synchron."""
|
||||
import main
|
||||
|
||||
assert main.PING_INTERVALL_SEKUNDEN < main.PING_ALTER_MAX_SEKUNDEN
|
||||
|
||||
|
||||
def test_tote_routen_sind_und_bleiben_weg():
|
||||
"""Entfernt am 25.07.2026, jede ein Überrest eines ersetzten Entwurfs und
|
||||
ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe
|
||||
des UI gegen alle Routen).
|
||||
|
||||
Der Test hält sie draussen. /stream/jobs ist der Grund für diese
|
||||
Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt
|
||||
entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden
|
||||
Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein
|
||||
EventSource im UI).
|
||||
"""
|
||||
from main import app
|
||||
|
||||
routen = {route.path for route in app.routes}
|
||||
for pfad in ("/prescan", "/jellyfin/format", "/stream/jobs",
|
||||
"/worker-setup/windows-gui"):
|
||||
assert pfad not in routen, (
|
||||
f"{pfad} ist wieder da — entweder mit Verbraucher (dann diesen Test "
|
||||
"anpassen) oder versehentlich (dann wieder raus)"
|
||||
)
|
||||
|
||||
|
||||
def test_worker_setup_routen_die_gebraucht_werden_sind_da():
|
||||
"""Die Installer holen sich Code und .exe hierueber — /worker-setup/paket
|
||||
ruft install.ps1 UND install-gui.ps1 auf, /windows-exe der UI-Knopf."""
|
||||
from main import app
|
||||
|
||||
routen = {route.path for route in app.routes}
|
||||
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
|
||||
"/worker-setup/windows-exe"):
|
||||
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
|
||||
|
||||
# --- Mount-Wache: heilt, was nach jedem Rebuild kaputt ist -------------------
|
||||
#
|
||||
# Hier statt in test_mounts_helpers.py, weil diese Tests `main` brauchen und
|
||||
# main.py haengt an fcntl (Linux). Dieses Modul ueberspringt sich unter Windows
|
||||
# selbst — es laeuft also genau da, wo auch die Ampel laeuft.
|
||||
|
||||
|
||||
def test_wache_ruehrt_nichts_an_solange_ein_job_laeuft(monkeypatch):
|
||||
"""Neu verbinden heisst `umount -l`. Mitten in einem Rip oder Encode waere
|
||||
das ein Datenverlust - die Wache muss dann stillstehen."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: True)
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
|
||||
lambda name: (_ for _ in ()).throw(AssertionError("nicht anfassen!")))
|
||||
|
||||
main._mounts_nachsehen() # darf einfach nichts tun
|
||||
|
||||
|
||||
def test_wache_verbindet_eine_stumme_freigabe_neu(monkeypatch):
|
||||
import main
|
||||
|
||||
repariert, gelogged = [], []
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append((lvl, msg)))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
|
||||
lambda *a, **k: repariert.append(a[0]))
|
||||
main._MOUNT_STAND.clear()
|
||||
|
||||
main._mounts_nachsehen()
|
||||
|
||||
assert repariert == ["rippy"]
|
||||
assert any("neu verbunden" in m for _, m in gelogged)
|
||||
|
||||
|
||||
def test_wache_meckert_nicht_jede_minute(monkeypatch):
|
||||
"""Ist das NAS ausgeschaltet, waere ein Log je Minute ein Wasserfall.
|
||||
Gemeldet wird nur der UEBERGANG."""
|
||||
import main
|
||||
|
||||
gelogged = []
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append(msg))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar", lambda name: False)
|
||||
|
||||
def reparieren_scheitert(*a, **k):
|
||||
raise RuntimeError("NAS aus")
|
||||
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren", reparieren_scheitert)
|
||||
main._MOUNT_STAND.clear()
|
||||
|
||||
for _ in range(5):
|
||||
main._mounts_nachsehen()
|
||||
|
||||
# "antwortet nicht" genau EINMAL (der Uebergang), die Fehlschlaege sind
|
||||
# jeweils eigene Meldungen - aber kein wiederholtes "antwortet nicht".
|
||||
assert len([m for m in gelogged if "antwortet nicht" in m]) == 1
|
||||
|
||||
|
||||
def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
|
||||
import main
|
||||
|
||||
gelogged = []
|
||||
zustand = {"da": False}
|
||||
monkeypatch.setattr(main.db, "list_mounts", lambda: [
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}])
|
||||
monkeypatch.setattr(main.db, "hat_arbeit", lambda: False)
|
||||
monkeypatch.setattr(main.db, "add_log",
|
||||
lambda lvl, src, msg: gelogged.append(msg))
|
||||
monkeypatch.setattr(main.mount_verwaltung, "ist_erreichbar",
|
||||
lambda name: zustand["da"])
|
||||
monkeypatch.setattr(main.mount_verwaltung, "reparieren",
|
||||
lambda *a, **k: None)
|
||||
main._MOUNT_STAND.clear()
|
||||
main._MOUNT_STAND["rippy"] = False
|
||||
|
||||
zustand["da"] = True
|
||||
main._mounts_nachsehen()
|
||||
assert any("antwortet wieder" in m for m in gelogged)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
|
||||
|
||||
import time
|
||||
|
||||
from auth import (
|
||||
add_to_blacklist,
|
||||
cleanup_blacklist,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_hash,
|
||||
is_access_token,
|
||||
is_blacklisted,
|
||||
is_refresh_token,
|
||||
token_blacklist,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_passwort_hash_roundtrip():
|
||||
hashed = get_password_hash("geheim123")
|
||||
assert hashed != "geheim123"
|
||||
assert verify_password("geheim123", hashed) is True
|
||||
assert verify_password("falsch", hashed) is False
|
||||
|
||||
|
||||
def test_access_token_roundtrip():
|
||||
token = create_access_token({"sub": "commander"})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "commander"
|
||||
assert payload["type"] == "access"
|
||||
assert is_access_token(token) is True
|
||||
assert is_refresh_token(token) is False
|
||||
|
||||
|
||||
def test_refresh_token_roundtrip():
|
||||
token = create_refresh_token({"sub": "commander"})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["type"] == "refresh"
|
||||
assert is_refresh_token(token) is True
|
||||
assert is_access_token(token) is False
|
||||
|
||||
|
||||
def test_muell_token_gibt_none_und_false():
|
||||
assert decode_token("kein.echter.token") is None
|
||||
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
|
||||
assert is_access_token("kein.echter.token") is False
|
||||
assert is_refresh_token("kein.echter.token") is False
|
||||
|
||||
|
||||
def test_blacklist_logout_wirkt():
|
||||
token = create_access_token({"sub": "commander"})
|
||||
assert is_blacklisted(token) is False
|
||||
add_to_blacklist(token)
|
||||
assert is_blacklisted(token) is True
|
||||
|
||||
|
||||
def test_cleanup_entfernt_nur_abgelaufene():
|
||||
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
|
||||
frisch = create_access_token({"sub": "commander"})
|
||||
add_to_blacklist(frisch)
|
||||
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
|
||||
|
||||
cleanup_blacklist()
|
||||
|
||||
assert "laengst-abgelaufener-token" not in token_blacklist
|
||||
assert is_blacklisted(frisch) is True
|
||||
@@ -15,7 +15,17 @@ def test_prescan_key_audio_vs_video():
|
||||
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
||||
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur.
|
||||
|
||||
Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der
|
||||
4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im
|
||||
Quellstrom gemessen, `/proc/<pid>/fdinfo/`) — hochgerechnet 28-55 Stunden. Genau
|
||||
dieser Fall muss eine ehrliche Antwort geben, statt „gleich fertig" zu behaupten.
|
||||
"""
|
||||
|
||||
import eta
|
||||
|
||||
|
||||
def test_ohne_genug_punkte_keine_aussage():
|
||||
assert eta.restzeit_sekunden(None, 100.0) == -1
|
||||
assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1
|
||||
einer = {"status": "transcoding", "punkte": [[0.0, 5]]}
|
||||
assert eta.restzeit_sekunden(einer, 100.0) == -1
|
||||
|
||||
|
||||
def test_zu_kurze_spanne_keine_aussage():
|
||||
"""Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden
|
||||
läuft — MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung."""
|
||||
reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]}
|
||||
assert eta.restzeit_sekunden(reihe, 10.0) == -1
|
||||
|
||||
|
||||
def test_kein_fortschritt_keine_aussage():
|
||||
"""Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt,
|
||||
nicht null. Ohne diese Sperre käme eine Division durch 0."""
|
||||
reihe = {"status": "transcoding", "punkte": [[0.0, 3]]}
|
||||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0)
|
||||
assert eta.restzeit_sekunden(reihe, 3600.0) == -1
|
||||
|
||||
|
||||
def test_einfache_hochrechnung():
|
||||
"""10 % in 10 Minuten → 90 % brauchen 90 Minuten."""
|
||||
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]}
|
||||
assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60
|
||||
|
||||
|
||||
def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage():
|
||||
"""Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung
|
||||
TAGE landen — das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am
|
||||
25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer
|
||||
„1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet."""
|
||||
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]}
|
||||
sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0)
|
||||
stunden = sekunden / 3600
|
||||
assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h
|
||||
assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h"
|
||||
# Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der
|
||||
# Größenordnung wäre jede Stundenangabe erfundene Genauigkeit.
|
||||
langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]}
|
||||
assert eta.formatiere_restzeit(
|
||||
eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage"
|
||||
|
||||
|
||||
def test_stillstand_verlaengert_die_schaetzung():
|
||||
"""Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen —
|
||||
sonst zeigt ein hängender Job stundenlang „noch 5 Minuten"."""
|
||||
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||
frisch = eta.restzeit_sekunden(reihe, 600.0)
|
||||
# ... eine Stunde später steht der Fortschritt immer noch bei 50 %
|
||||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0)
|
||||
spaeter = eta.restzeit_sekunden(reihe, 4200.0)
|
||||
assert spaeter > frisch * 5
|
||||
|
||||
|
||||
def test_statuswechsel_verwirft_die_reihe():
|
||||
"""Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun.
|
||||
Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl."""
|
||||
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]}
|
||||
neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0)
|
||||
assert neu["status"] == "transcoding"
|
||||
assert neu["punkte"] == [[610.0, 2]]
|
||||
assert eta.restzeit_sekunden(neu, 610.0) == -1
|
||||
|
||||
|
||||
def test_nur_das_fenster_zaehlt():
|
||||
"""Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer,
|
||||
und dann lügt der Anfang der Messreihe."""
|
||||
reihe = None
|
||||
for i in range(20):
|
||||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60))
|
||||
assert len(reihe["punkte"]) == eta.FENSTER
|
||||
assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg
|
||||
|
||||
|
||||
def test_gleicher_fortschritt_haengt_keinen_punkt_an():
|
||||
reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0)
|
||||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0)
|
||||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0)
|
||||
assert reihe["punkte"] == [[0.0, 7]]
|
||||
|
||||
|
||||
def test_fertig_ist_null():
|
||||
reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]}
|
||||
assert eta.restzeit_sekunden(reihe, 600.0) == 0
|
||||
assert eta.formatiere_restzeit(0) == "unter einer Minute"
|
||||
|
||||
|
||||
# --- Textform ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_textform_deckt_alle_groessenordnungen():
|
||||
assert eta.formatiere_restzeit(-1) == ""
|
||||
assert eta.formatiere_restzeit(None) == ""
|
||||
assert eta.formatiere_restzeit(30) == "unter einer Minute"
|
||||
assert eta.formatiere_restzeit(90) == "noch ca. 1 min"
|
||||
assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min"
|
||||
assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min"
|
||||
assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h"
|
||||
assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage"
|
||||
|
||||
|
||||
# --- Der Weg über den Cache -------------------------------------------------
|
||||
|
||||
|
||||
def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe():
|
||||
"""So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf
|
||||
schreibt die Reihe fort."""
|
||||
speicher = {}
|
||||
|
||||
def hole(k):
|
||||
return speicher.get(k)
|
||||
|
||||
def lege(k, wert, expire=None):
|
||||
speicher[k] = wert
|
||||
|
||||
erst = eta.aktualisiere_und_schaetze(
|
||||
"job1", "transcoding", 10, 0.0, hole, lege)
|
||||
assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts
|
||||
|
||||
dann = eta.aktualisiere_und_schaetze(
|
||||
"job1", "transcoding", 20, 600.0, hole, lege)
|
||||
assert dann["sekunden"] == 80 * 60
|
||||
assert dann["text"] == "noch ca. 1 h 20 min"
|
||||
|
||||
|
||||
def test_kaputter_cache_bringt_nichts_zum_absturz():
|
||||
"""Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren."""
|
||||
def kaputt_holen(k):
|
||||
raise RuntimeError("Redis weg")
|
||||
|
||||
def kaputt_legen(k, wert, expire=None):
|
||||
raise RuntimeError("Redis weg")
|
||||
|
||||
ergebnis = eta.aktualisiere_und_schaetze(
|
||||
"job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen)
|
||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||
|
||||
|
||||
def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
||||
speicher = {}
|
||||
for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)):
|
||||
ergebnis = eta.aktualisiere_und_schaetze(
|
||||
"x", status, progress, 0.0,
|
||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||
assert speicher == {} # nichts geschrieben
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Was das Ähnlichkeits-Gate von Jikan wirklich durchlässt — gemessen.
|
||||
|
||||
Hintergrund (26.07.2026): Der SAVEPOINT v3.16 vermutete, das Gate 0,55 sei
|
||||
„großzügig" und Jikan stehe in der Metadaten-Kette zu früh (vor OMDb). Beides
|
||||
war nachrechenbar, weil `titel_aehnlichkeit` eine reine Funktion ist — und die
|
||||
Rechnung ergab ein anderes Bild als die Vermutung:
|
||||
|
||||
- Für kurze Kinofilm-Titel ist das Gate WIRKLICH zu locker (Alien → 0,83).
|
||||
- Für den Fall, für den Jikan eingebaut wurde, ist es sogar zu STRENG
|
||||
(„Evangelion 2.22" gegen den MAL-Titel → 0,51, fällt durch).
|
||||
|
||||
Eine einzelne Zahl kann also beides nicht leisten. Die Folge steht in
|
||||
prescan._scan_video: „exakt schlägt unscharf" statt „erster gewinnt".
|
||||
|
||||
Die Anime-Titel unten existieren auf MyAnimeList; die Zahlen sind ausgerechnet,
|
||||
nicht geschätzt. MyAnimeList selbst war bei dieser Messung nicht erreichbar
|
||||
(Jikan antwortete durchweg HTTP 504) — die Live-Abfrage steht damit weiter aus,
|
||||
die Arithmetik des Gates ist davon aber unberührt.
|
||||
"""
|
||||
|
||||
from clients.jikan import JikanClient, titel_aehnlichkeit
|
||||
|
||||
|
||||
def test_kurze_kinofilm_titel_reissen_das_gate_0_55():
|
||||
"""Diese vier würden ohne die Änderung Anime-Metadaten bekommen, obwohl
|
||||
OMDb die Filme kennt — es wurde nur nie gefragt."""
|
||||
faelle = [
|
||||
("Alien", "Alien 9", 0.83),
|
||||
("Inception", "Deception", 0.78),
|
||||
("Hero", "Heroman", 0.73),
|
||||
("The Dark Knight", "Dark Knight Rises", 0.69),
|
||||
]
|
||||
for disc, anime, erwartet in faelle:
|
||||
score = titel_aehnlichkeit(disc, anime)
|
||||
assert round(score, 2) == erwartet, f"{disc} vs {anime}: {score}"
|
||||
# ... liegt über dem Gate (Jikan gibt also einen Treffer zurück) ...
|
||||
assert score >= JikanClient.MINDEST_AEHNLICHKEIT
|
||||
# ... aber unter „sicher", darf die Kette also nicht beenden.
|
||||
assert score < JikanClient.SICHER_AEHNLICHKEIT
|
||||
|
||||
|
||||
def test_echter_treffer_gilt_weiter_als_sicher():
|
||||
"""Exakter Titel → 1,0. Der Normalfall eines echten Anime-Fundes darf durch
|
||||
die Verschärfung NICHT verlorengehen (AGENTS Regel B)."""
|
||||
assert titel_aehnlichkeit("Monster", "Monster") == 1.0
|
||||
assert titel_aehnlichkeit("Cowboy Bebop", "COWBOY BEBOP") == 1.0
|
||||
# Interpunktion und Kleinschreibung sind egal — norm() wirft sie weg
|
||||
assert titel_aehnlichkeit("Akira", "AKIRA!") == 1.0
|
||||
for a, b in (("Monster", "Monster"), ("Akira", "AKIRA!")):
|
||||
assert titel_aehnlichkeit(a, b) >= JikanClient.SICHER_AEHNLICHKEIT
|
||||
|
||||
|
||||
def test_untertitel_zaehlt_noch_als_sicher_genug():
|
||||
"""Discs tragen oft den Kurztitel, MAL den vollen. Das muss ein sicherer
|
||||
Treffer bleiben, sonst hilft die Quelle bei Anime nicht mehr."""
|
||||
assert titel_aehnlichkeit("Steins Gate", "Steins;Gate") >= JikanClient.SICHER_AEHNLICHKEIT
|
||||
|
||||
|
||||
def test_der_fall_fuer_den_jikan_gebaut_wurde_faellt_heute_durch():
|
||||
"""Gegenbeweis zur Vermutung „das Gate ist zu großzügig": Für Evangelion
|
||||
2.22 ist es zu STRENG. Der Wert ist dokumentiert, damit ein späteres
|
||||
Anheben des Gates nicht unbemerkt auch diesen Fall killt."""
|
||||
score = titel_aehnlichkeit(
|
||||
"Evangelion 2.22", "Evangelion: 2.0 You Can (Not) Advance"
|
||||
)
|
||||
assert round(score, 2) == 0.51
|
||||
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||
|
||||
|
||||
def test_voellig_anderer_titel_wird_verworfen():
|
||||
score = titel_aehnlichkeit("Blade", "Blade of the Immortal")
|
||||
assert score < JikanClient.MINDEST_AEHNLICHKEIT
|
||||
|
||||
|
||||
def test_leere_titel_geben_null():
|
||||
assert titel_aehnlichkeit("", "Monster") == 0.0
|
||||
assert titel_aehnlichkeit("Monster", "") == 0.0
|
||||
assert titel_aehnlichkeit("!!!", "???") == 0.0
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests für die puren Helfer aus makemkv_daten.
|
||||
|
||||
Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
|
||||
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
|
||||
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
|
||||
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
|
||||
das Zusammenfuehren der settings.conf.
|
||||
"""
|
||||
from makemkv_daten import (
|
||||
ist_aacs_dump,
|
||||
keydb_pruefen,
|
||||
settings_conf_zusammenfuehren,
|
||||
zaehle_disc_eintraege,
|
||||
)
|
||||
|
||||
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
|
||||
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
|
||||
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
|
||||
_GUELTIG = """; KEYDB.cfg — Beispiel
|
||||
|
||||
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
|
||||
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
|
||||
"""
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
|
||||
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
|
||||
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
|
||||
assert zaehle_disc_eintraege(_GUELTIG) == 2
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
|
||||
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
|
||||
assert zaehle_disc_eintraege(nur_kommentare) == 0
|
||||
assert zaehle_disc_eintraege("") == 0
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
|
||||
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
|
||||
|
||||
|
||||
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
|
||||
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
|
||||
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
|
||||
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
|
||||
|
||||
|
||||
def test_keydb_pruefen_meldet_leere_datei():
|
||||
assert keydb_pruefen("") != ""
|
||||
assert keydb_pruefen(" \n\n ") != ""
|
||||
|
||||
|
||||
def test_keydb_pruefen_erkennt_html():
|
||||
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
|
||||
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
|
||||
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
|
||||
assert "HTML" in fehler
|
||||
|
||||
|
||||
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
|
||||
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
|
||||
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
|
||||
|
||||
|
||||
def test_keydb_pruefen_laesst_gueltige_datei_durch():
|
||||
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
|
||||
assert keydb_pruefen(_GUELTIG) == ""
|
||||
|
||||
|
||||
def test_ist_aacs_dump_erkennt_echten_namen():
|
||||
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
|
||||
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
|
||||
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
|
||||
|
||||
|
||||
def test_ist_aacs_dump_blockt_pfad_tricks():
|
||||
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
|
||||
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
|
||||
assert ist_aacs_dump("../x.tgz") is False
|
||||
assert ist_aacs_dump("../../etc/passwd.tgz") is False
|
||||
assert ist_aacs_dump("unter/ordner.tgz") is False
|
||||
assert ist_aacs_dump("unter\\ordner.tgz") is False
|
||||
assert ist_aacs_dump(".versteckt.tgz") is False
|
||||
|
||||
|
||||
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
|
||||
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
|
||||
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
|
||||
assert ist_aacs_dump("KEYDB.cfg") is False
|
||||
assert ist_aacs_dump("settings.conf") is False
|
||||
assert ist_aacs_dump("_private_data.tar") is False
|
||||
assert ist_aacs_dump("") is False
|
||||
|
||||
|
||||
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
|
||||
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
|
||||
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
|
||||
# Rip weg gewesen.
|
||||
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
|
||||
neu = settings_conf_zusammenfuehren(alt, "T-neu")
|
||||
assert 'app_Key = "T-neu"' in neu
|
||||
assert "T-alt" not in neu
|
||||
assert 'app_UpdateEnable = "1"' in neu
|
||||
assert 'app_DestinationDir = "/tmp"' in neu
|
||||
|
||||
|
||||
def test_settings_conf_leerer_key_entfernt_die_zeile():
|
||||
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
|
||||
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
|
||||
assert "app_Key" not in neu
|
||||
assert 'app_UpdateEnable = "1"' in neu
|
||||
|
||||
|
||||
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
|
||||
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
|
||||
# Datei mit abschliessendem Zeilenumbruch.
|
||||
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
|
||||
assert settings_conf_zusammenfuehren("", "") == ""
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||
from makemkv_key import extract_key
|
||||
|
||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||
_KEY = "T-BSaJ6gwgMx4eIggWkVYXiVP_6zehm7WAO9dEydvzOHFHoZ6YQ82BL5cGpYDxvyRWnS"
|
||||
|
||||
|
||||
def test_extract_key_findet_key_im_html():
|
||||
html = f'<div class="content"><pre><code>{_KEY}</code></pre></div>'
|
||||
assert extract_key(html) == _KEY
|
||||
|
||||
|
||||
def test_extract_key_ohne_key_gibt_none():
|
||||
assert extract_key("<html>kein Schluessel hier</html>") is None
|
||||
assert extract_key("") is None
|
||||
|
||||
|
||||
def test_extract_key_zu_kurz_kein_treffer():
|
||||
# Format ist streng T- + genau 64 Zeichen — zu kurz fällt raus.
|
||||
assert extract_key("T-zukurz123") is None
|
||||
|
||||
|
||||
def test_extract_key_liefert_ganzen_key():
|
||||
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind länger
|
||||
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
|
||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||
assert treffer == _KEY
|
||||
assert treffer.startswith("T-")
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||
|
||||
from mounts import (
|
||||
pfad_map_vorschlag,
|
||||
pfad_map_zeile,
|
||||
uebersetze_smb_fehler,
|
||||
unc_aus_quelle,
|
||||
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")
|
||||
|
||||
|
||||
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
aufrufe = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
aufrufe.append(cmd)
|
||||
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||
|
||||
|
||||
def test_mounten_loest_stale_vor_dem_mount():
|
||||
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||
import inspect
|
||||
|
||||
import mounts
|
||||
|
||||
quelle = inspect.getsource(mounts.mounten)
|
||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||
|
||||
|
||||
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
|
||||
|
||||
|
||||
def test_unc_aus_quelle_uebersetzt_cifs():
|
||||
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
|
||||
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
|
||||
|
||||
|
||||
def test_unc_aus_quelle_raet_bei_nfs_nicht():
|
||||
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
|
||||
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
|
||||
assert unc_aus_quelle("cifs", "") == ""
|
||||
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
|
||||
|
||||
|
||||
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
|
||||
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
|
||||
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||
vorschlaege = pfad_map_vorschlag([
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||
])
|
||||
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
|
||||
|
||||
|
||||
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
|
||||
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
|
||||
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
|
||||
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||
vorschlaege = pfad_map_vorschlag([
|
||||
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
|
||||
])
|
||||
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
|
||||
|
||||
|
||||
def test_pfad_map_zeile_ohne_mounts_ist_leer():
|
||||
assert pfad_map_zeile([]) == ""
|
||||
assert pfad_map_zeile(None) == ""
|
||||
|
||||
|
||||
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
||||
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
|
||||
|
||||
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der
|
||||
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
|
||||
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
|
||||
Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb
|
||||
wird sie über den Pfad importiert.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
|
||||
mapping = pfad_map_zeile(pfad_map_vorschlag([
|
||||
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
|
||||
]))
|
||||
|
||||
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
||||
# den Containern, deshalb per Pfad laden statt importieren.
|
||||
worker_tasks = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
||||
quelltext = open(worker_tasks, encoding="utf-8").read()
|
||||
assert "def pfad_lokal" in quelltext and spec is not None
|
||||
|
||||
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
|
||||
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
|
||||
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
|
||||
anfang = quelltext.index("def pfad_lokal")
|
||||
ende = quelltext.index("\nRAW_DIR", anfang)
|
||||
umgebung = {"os": os}
|
||||
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
|
||||
pfad_lokal = umgebung["pfad_lokal"]
|
||||
|
||||
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
|
||||
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
|
||||
)
|
||||
|
||||
|
||||
# --- Der Mount kam nach einem Rebuild nicht zurueck (Befund 26.07.2026) ------
|
||||
|
||||
|
||||
def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch):
|
||||
"""Regression. Vorher galt `os.path.ismount` als Beweis, dass alles steht -
|
||||
und nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
|
||||
Zugriffen 10 s Timeout), der Mountpunkt aber weiter vorhanden. Damit brach
|
||||
das Wiederherstellen genau dort ab, und `schreibtest()` (kein Timeout!)
|
||||
blockierte den Start-Thread im Kernel.
|
||||
|
||||
Antwortet die Freigabe nicht, muss geloest und frisch gemountet werden -
|
||||
genau wie reparieren() es tut, nur automatisch. Der Test faengt das an der
|
||||
WIRKUNG: ismount darf dann gar nicht mehr gefragt werden."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
ablauf = []
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
# pfad_lage gestubbt: Sie ruft selbst `timeout ls` auf, und hier geht es um
|
||||
# die Reihenfolge von Loesen und Mounten.
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
|
||||
# Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die
|
||||
# Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet.
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
|
||||
lambda ziel: ablauf.append("loesen"))
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
ablauf.append(cmd[0])
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
# ismount darf hier gar nicht mehr gefragt werden
|
||||
monkeypatch.setattr(mounts.os.path, "ismount",
|
||||
lambda p: (_ for _ in ()).throw(AssertionError("zu frueh gefragt")))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
assert ablauf == ["loesen", "mount"]
|
||||
|
||||
|
||||
def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch):
|
||||
"""Befund 26.07.2026, dreimal reproduziert: `mount` meldete Erfolg, und die
|
||||
Freigabe antwortete danach TROTZDEM nicht (eine einzige, korrekt aussehende
|
||||
Schicht in /proc/mounts). Derselbe Ablauf ein zweites Mal stellte sie sofort
|
||||
her. Also wird das Ergebnis geprueft statt geglaubt."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
ablauf = []
|
||||
# nie erreichbar: vorher, nach Versuch 1, nach Versuch 2
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da")
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None)
|
||||
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: ablauf.append("lazy"))
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: (ablauf.append(cmd[0]),
|
||||
types.SimpleNamespace(returncode=0, stdout="", stderr=""))[1])
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError) as fehler:
|
||||
mounts.mounten("rippy", "cifs", "//nas/rippy")
|
||||
|
||||
# Zweimal gemountet, dazwischen einmal geloest
|
||||
assert ablauf == ["mount", "lazy", "mount"]
|
||||
# Und die Meldung sagt die Wahrheit statt "eingehaengt"
|
||||
assert "antwortet aber nicht" in str(fehler.value)
|
||||
|
||||
|
||||
def test_mounten_laesst_gesunden_mount_in_ruhe(monkeypatch):
|
||||
"""Antwortet die Freigabe, bleibt sie unangetastet - kein Loesen, kein
|
||||
zweites Mounten (das wuerde stapeln)."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts.os.path, "ismount", lambda p: True)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
|
||||
lambda ziel: (_ for _ in ()).throw(AssertionError("nicht loesen!")))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_prueft_zweimal_mit_abstand(monkeypatch):
|
||||
"""Befund 26.07.2026: Direkt nach einem frischen `mount` antwortete die
|
||||
Freigabe - und Sekunden spaeter lief jeder Zugriff in die Zeitgrenze
|
||||
(Wettlauf mit dem lazy umount, dessen Abbau hinter den neuen Mount fiel).
|
||||
Eine EINZIGE Probe kann das nicht sehen."""
|
||||
import mounts
|
||||
|
||||
antworten = iter([True, False]) # erst ja, dann nein
|
||||
gewartet = []
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
||||
assert gewartet == [3]
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_bei_gesunder_freigabe(monkeypatch):
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=lambda s: None) is True
|
||||
|
||||
|
||||
def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt(monkeypatch):
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
||||
assert gewartet == [] # nicht drei Sekunden fuer nichts
|
||||
|
||||
|
||||
def test_stale_loesen_wartet_den_lazy_abbau_ab(monkeypatch):
|
||||
"""Der Grund fuer die 150 Sekunden (Befund 26.07.2026): `umount -l` ist lazy,
|
||||
der Abbau passiert spaeter. Wer direkt danach mountet, riskiert, dass der
|
||||
Abbau HINTER dem neuen Mount landet - der erste Reparaturversuch scheiterte
|
||||
dadurch regelmaessig, und der zweite kostete 30 s Timeout."""
|
||||
import time
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(time, "sleep", gewartet.append)
|
||||
aufrufe = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
aufrufe.append(cmd)
|
||||
rc = 0 if len(aufrufe) <= 2 else 1
|
||||
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 2
|
||||
assert gewartet == [1.5]
|
||||
|
||||
|
||||
def test_stale_loesen_wartet_nicht_wenn_nichts_zu_loesen_war(monkeypatch):
|
||||
"""War kein Mount da, gibt es auch keinen Abbau abzuwarten - dann darf die
|
||||
Reparatur nicht kuenstlich gebremst werden."""
|
||||
import time
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gewartet = []
|
||||
monkeypatch.setattr(time, "sleep", gewartet.append)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=1, stdout=b"", stderr=b""))
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 0
|
||||
assert gewartet == []
|
||||
|
||||
|
||||
def test_pfad_lage_unterscheidet_da_weg_unklar(monkeypatch):
|
||||
"""DER Fund vom 26.07.2026: Eine Wiederanbindung brauchte 3 min 15 s, und die
|
||||
Zeit ging in die ERSTE Zeile von mounten() - `os.makedirs(ziel,
|
||||
exist_ok=True)`. `exist_ok` prueft mit os.path.isdir, und ein `stat` auf einen
|
||||
toten CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Deshalb wird die
|
||||
Lage jetzt mit einem abbrechbaren Kind-Prozess erfragt."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
def antwort(rc):
|
||||
return lambda cmd, **k: types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(0))
|
||||
assert mounts.pfad_lage("/app/media/rippy") == "da"
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(2))
|
||||
assert mounts.pfad_lage("/app/media/neu") == "weg"
|
||||
# 124 = `timeout` hat abgeschossen: existiert, antwortet aber nicht
|
||||
monkeypatch.setattr(mounts.subprocess, "run", antwort(124))
|
||||
assert mounts.pfad_lage("/app/media/totes-nas") == "unklar"
|
||||
|
||||
|
||||
def test_pfad_lage_nutzt_eine_zeitgrenze(monkeypatch):
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
gesehen = {}
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: (gesehen.update(cmd=cmd, kw=k),
|
||||
types.SimpleNamespace(returncode=0))[1])
|
||||
mounts.pfad_lage("/x")
|
||||
assert gesehen["cmd"][0] == "timeout"
|
||||
assert gesehen["kw"]["timeout"] is not None
|
||||
|
||||
|
||||
def test_mounten_legt_den_ordner_nur_an_wenn_er_fehlt(monkeypatch):
|
||||
"""Bei "unklar" (toter Mount) darf makedirs NICHT laufen - genau dort hing es
|
||||
drei Minuten. Der Ordner ist dann ohnehin da."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
angelegt = []
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "unklar")
|
||||
monkeypatch.setattr(mounts.os, "makedirs",
|
||||
lambda *a, **k: angelegt.append(a[0] if a else ""))
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
|
||||
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
|
||||
assert angelegt == []
|
||||
|
||||
|
||||
def test_mounten_legt_den_ordner_bei_erstinstallation_an(monkeypatch):
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
angelegt = []
|
||||
monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "weg")
|
||||
monkeypatch.setattr(mounts.os, "makedirs",
|
||||
lambda *a, **k: angelegt.append(a[0] if a else ""))
|
||||
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
|
||||
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0)
|
||||
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
|
||||
monkeypatch.setattr(
|
||||
mounts.subprocess, "run",
|
||||
lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
|
||||
mounts.mounten("rippy", "cifs", "//nas/rippy")
|
||||
assert angelegt == ["/app/media/rippy"]
|
||||
|
||||
|
||||
def test_reparieren_fragt_ismount_nicht_mehr(monkeypatch):
|
||||
"""os.path.ismount ist ein `stat` und blockiert auf einem toten CIFS-Mount.
|
||||
Gebraucht wird es nicht: `umount -l` auf einen leeren Pfad kostet nichts."""
|
||||
import mounts
|
||||
|
||||
monkeypatch.setattr(mounts.os.path, "ismount",
|
||||
lambda p: (_ for _ in ()).throw(AssertionError("nicht fragen!")))
|
||||
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: None)
|
||||
monkeypatch.setattr(mounts, "mounten", lambda *a, **k: True)
|
||||
assert mounts.reparieren("rippy", "cifs", "//nas/rippy") is True
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests für die Phasen-Erkennung — was darf der „Neu"-Knopf anbieten?"""
|
||||
|
||||
import json
|
||||
|
||||
import phasen
|
||||
|
||||
|
||||
def test_laufender_job_bietet_nichts():
|
||||
assert phasen.retry_art({"status": "running"}) == phasen.NICHTS
|
||||
assert phasen.retry_art({"status": "completed"}) == phasen.NICHTS
|
||||
|
||||
|
||||
def test_toter_transcode_bietet_komprimieren():
|
||||
job = {"status": "failed", "meta": json.dumps({"rip_fertig": True})}
|
||||
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||
|
||||
|
||||
def test_toter_rip_bietet_rippen():
|
||||
"""Der Vorfall vom 26.07.2026: 5,1 GB von 40 GB. Komprimieren wäre falsch."""
|
||||
job = {"status": "failed", "meta": json.dumps({"rip_fertig": False, "year": 1988})}
|
||||
assert phasen.retry_art(job) == phasen.NEU_RIPPEN
|
||||
|
||||
|
||||
def test_bestandsjob_ohne_marke_ist_unklar():
|
||||
"""Jobs von VOR dieser Änderung dürfen nicht geraten werden."""
|
||||
assert phasen.retry_art({"status": "failed", "meta": None}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art(
|
||||
{"status": "failed", "meta": json.dumps({"year": 1988})}
|
||||
) == phasen.UNKLAR
|
||||
|
||||
|
||||
def test_kaputtes_json_ist_unklar_statt_absturz():
|
||||
"""/jobs darf an einer krummen meta-Zeile nicht scheitern."""
|
||||
assert phasen.retry_art({"status": "failed", "meta": "{kein json"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed", "meta": "[1,2]"}) == phasen.UNKLAR
|
||||
assert phasen.retry_art({"status": "failed", "meta": 7}) == phasen.UNKLAR
|
||||
|
||||
|
||||
def test_marke_wird_auch_als_dict_gelesen():
|
||||
"""Der Detail-Endpunkt hat die Metadaten schon geparst."""
|
||||
job = {"status": "failed", "meta": {"rip_fertig": True}}
|
||||
assert phasen.retry_art(job) == phasen.NEU_KOMPRIMIEREN
|
||||
|
||||
|
||||
def test_der_schluessel_heisst_im_worker_genauso():
|
||||
"""Zwei Container, kein geteiltes Paket — die Marke muss zusammenpassen.
|
||||
|
||||
Ohne diese Prüfung ist ein Tippfehler in einer der beiden Dateien lautlos:
|
||||
Der Worker schreibt `rip_fertig`, die API liest `ripFertig`, und JEDER Job
|
||||
wäre für immer „unklar". Genau diese Sorte Auseinanderdriften hat die
|
||||
Zombie-Erkennung ein Release lang blind gemacht („running" vs. „ripping").
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
quelle = (
|
||||
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
|
||||
).read_text(encoding="utf-8")
|
||||
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
||||
# ankommen.
|
||||
treffer = re.search(r'^RIP_FERTIG\s*=\s*"([^"]+)"', quelle, re.MULTILINE)
|
||||
assert treffer, "worker/tasks.py definiert RIP_FERTIG nicht mehr"
|
||||
assert treffer.group(1) == phasen.RIP_FERTIG
|
||||
@@ -1,7 +1,26 @@
|
||||
"""Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing)."""
|
||||
|
||||
from clients.omdb import parse_runtime_minutes, parse_year
|
||||
from prescan.prescan import normalize_disc_label
|
||||
from prescan.prescan import normalize_disc_label, parse_udf_dstring
|
||||
|
||||
|
||||
def test_udf_dstring_latin1():
|
||||
# d-string: [comp_id=8]["MEIN_FILM"][padding][len=10]
|
||||
daten = bytes([8]) + b"MEIN_FILM" + bytes(21) + bytes([10])
|
||||
assert parse_udf_dstring(daten) == "MEIN_FILM"
|
||||
|
||||
|
||||
def test_udf_dstring_utf16():
|
||||
text = "FILM".encode("utf-16-be")
|
||||
daten = bytes([16]) + text + bytes(32 - 2 - len(text)) + bytes([1 + len(text)])
|
||||
assert parse_udf_dstring(daten) == "FILM"
|
||||
|
||||
|
||||
def test_udf_dstring_kaputt_gibt_leer():
|
||||
assert parse_udf_dstring(b"") == ""
|
||||
assert parse_udf_dstring(bytes(32)) == "" # Länge 0
|
||||
daten = bytes([8]) + b"X" + bytes(29) + bytes([99]) # Länge > Puffer
|
||||
assert parse_udf_dstring(daten) == ""
|
||||
|
||||
|
||||
def test_label_normalisierung():
|
||||
@@ -28,3 +47,100 @@ def test_omdb_jahr_parsing():
|
||||
assert parse_year("2005–2012") == 2005 # Serien-Zeitspanne
|
||||
assert parse_year("N/A") is None
|
||||
assert parse_year("") is None
|
||||
|
||||
|
||||
# --- Reihenfolge der Metadaten-Quellen: exakt schlägt unscharf ---------------
|
||||
#
|
||||
# Änderung vom 26.07.2026. Vorher galt der erste Jikan-Treffer über dem Gate
|
||||
# 0,55 sofort als sicherer Fund, und OMDb kam nie dran — ein Kinofilm, den TMDB
|
||||
# verpasst, bekam damit Anime-Metadaten. Die Messwerte dazu stehen in
|
||||
# test_jikan_helpers.py („Alien" gegen „Alien 9" = 0,83).
|
||||
|
||||
|
||||
class _StummerClient:
|
||||
"""Ein Client, der auf jede Frage dasselbe antwortet — und mitzählt."""
|
||||
|
||||
def __init__(self, antwort=None):
|
||||
self.antwort = antwort
|
||||
self.aufrufe = 0
|
||||
|
||||
def lookup(self, *a, **k):
|
||||
self.aufrufe += 1
|
||||
return self.antwort
|
||||
|
||||
def search_movie(self, *a, **k):
|
||||
return []
|
||||
|
||||
def search_tv(self, *a, **k):
|
||||
return []
|
||||
|
||||
def get_movie_details(self, *a, **k):
|
||||
return None
|
||||
|
||||
def find_by_imdb(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def _scanner(monkeypatch, jikan_antwort, omdb_antwort):
|
||||
"""PreScan ohne __init__ (das bräuchte DB + API-Keys), Cache stillgelegt."""
|
||||
from prescan import prescan as modul
|
||||
|
||||
monkeypatch.setattr(modul, "get", lambda k: None)
|
||||
monkeypatch.setattr(modul, "cache_set", lambda *a, **k: None)
|
||||
|
||||
scan = modul.PreScan.__new__(modul.PreScan)
|
||||
scan.tmdb = _StummerClient()
|
||||
scan.jikan = _StummerClient(jikan_antwort)
|
||||
scan.omdb = _StummerClient(omdb_antwort)
|
||||
return scan
|
||||
|
||||
|
||||
def _jikan(score):
|
||||
return {"type": "movie", "id": "mal-1", "title": "Alien 9",
|
||||
"source": "jikan", "match_score": score}
|
||||
|
||||
|
||||
def _omdb():
|
||||
return {"type": "movie", "id": "tt0078748", "title": "Alien",
|
||||
"source": "omdb", "year": 1979}
|
||||
|
||||
|
||||
def test_unscharfer_jikan_treffer_laesst_omdb_zum_zug(monkeypatch):
|
||||
"""Der eigentliche Fehler: „Alien" traf mit 0,83 auf den Anime „Alien 9",
|
||||
und der Film wurde nie gesucht."""
|
||||
scan = _scanner(monkeypatch, _jikan(0.83), _omdb())
|
||||
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||
assert ergebnis.metadata["source"] == "omdb"
|
||||
assert ergebnis.confidence == 0.8
|
||||
assert scan.omdb.aufrufe > 0
|
||||
|
||||
|
||||
def test_exakter_jikan_treffer_gewinnt_sofort(monkeypatch):
|
||||
"""Der Fall, für den Jikan gebaut wurde, darf NICHT verlorengehen
|
||||
(AGENTS Regel B): Bei einem sicheren Treffer wird OMDb nicht mehr gefragt."""
|
||||
scan = _scanner(monkeypatch, _jikan(1.0), _omdb())
|
||||
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien 9", "disc_type": "Blu-ray"})
|
||||
assert ergebnis.metadata["source"] == "jikan"
|
||||
assert ergebnis.confidence == 0.85
|
||||
assert scan.omdb.aufrufe == 0
|
||||
|
||||
|
||||
def test_unscharfer_jikan_treffer_bleibt_als_vorschlag(monkeypatch):
|
||||
"""Hat OMDb nichts (kein Key, kein Treffer), ist der unscharfe Jikan-Treffer
|
||||
besser als nichts — aber er wird als VORSCHLAG ausgewiesen (0,6), nicht als
|
||||
sicherer Fund."""
|
||||
scan = _scanner(monkeypatch, _jikan(0.83), None)
|
||||
ergebnis = scan._scan_video("/dev/sr0", {"title": "Alien", "disc_type": "Blu-ray"})
|
||||
assert ergebnis.metadata["source"] == "jikan"
|
||||
assert ergebnis.confidence == 0.6
|
||||
|
||||
|
||||
def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
||||
"""Antwortet keine Quelle, bleibt es beim Disc-Titel mit Confidence 0,3 und
|
||||
`type: unknown` — der Bestand, unverändert. Der Rip laeuft trotzdem, die
|
||||
Datei heisst dann nur wie das Disc-Label."""
|
||||
scan = _scanner(monkeypatch, None, None)
|
||||
ergebnis = scan._scan_video("/dev/sr0", {"title": "Nix Da", "disc_type": "DVD"})
|
||||
assert ergebnis.confidence == 0.3
|
||||
assert ergebnis.metadata["type"] == "unknown"
|
||||
assert ergebnis.title == "Nix Da"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Tests der Preset-Auswahl — reine Funktionen, keine Infrastruktur.
|
||||
|
||||
Die Preset-Namen in den Testdaten sind ECHT: `HandBrakeCLI --preset-list` im
|
||||
Worker-Image der Rippy-VM, 26.07.2026, HandBrake 1.6.1 (AGENTS Regel D).
|
||||
Erfundene Namen hätten hier keinen Wert — genau daran ist das Projekt zweimal
|
||||
gescheitert.
|
||||
"""
|
||||
|
||||
import presets
|
||||
|
||||
# Ausschnitt der echten Liste: die Kategorien, aus denen Rippy wählt.
|
||||
PRESETS_ECHT = [
|
||||
# General/
|
||||
"Very Fast 2160p60 4K AV1", "Very Fast 1080p30",
|
||||
"HQ 2160p60 4K HEVC Surround", "HQ 1080p30 Surround", "HQ 576p25 Surround",
|
||||
"Super HQ 2160p60 4K HEVC Surround", "Super HQ 1080p30 Surround",
|
||||
# Matroska/
|
||||
"AV1 MKV 2160p60 4K",
|
||||
"H.265 MKV 2160p60 4K", "H.265 MKV 1080p30", "H.265 MKV 576p25",
|
||||
"H.265 MKV 480p30", "H.264 MKV 1080p30",
|
||||
# Hardware/ — steht auch auf Maschinen OHNE Hardware-Encoder in der Liste
|
||||
"AV1 QSV 2160p 4K",
|
||||
"H.265 NVENC 2160p 4K", "H.265 NVENC 1080p",
|
||||
"H.265 QSV 2160p 4K", "H.265 QSV 1080p",
|
||||
"H.265 VCN 2160p 4K", "H.265 VCN 1080p",
|
||||
"H.265 MF 2160p 4K", "H.265 MF 1080p",
|
||||
]
|
||||
|
||||
|
||||
def worker(name="w", encoders=None, simd="", presets_liste=None):
|
||||
return {
|
||||
"name": name,
|
||||
"encoders": encoders if encoders is not None else ["cpu-x264", "cpu-x265"],
|
||||
"info": {
|
||||
"cpu_simd": simd,
|
||||
"presets": presets_liste if presets_liste is not None else PRESETS_ECHT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- Die Rippy-VM: sse4_2, 4 Kerne, kein Hardware-Encoder --------------------
|
||||
|
||||
|
||||
def test_vm_ohne_avx2_bekommt_4k_verlustfrei():
|
||||
"""Gemessen: 4K-HEVC in Software = 28-55 h auf dieser Maschine. Die
|
||||
Empfehlung muss deshalb „nicht komprimieren" lauten, nicht ein 4K-Preset."""
|
||||
w = [worker(simd="sse4_2")]
|
||||
e = presets.empfehlung("uhd", w)
|
||||
assert e["preset"] == presets.PRESET_KEINE
|
||||
assert "28-55 Stunden" in e["grund"]
|
||||
|
||||
|
||||
def test_vm_ohne_avx2_bekommt_h264_fuer_bluray_und_dvd():
|
||||
w = [worker(simd="sse4_2")]
|
||||
assert presets.empfehlung("bluray", w)["preset"] == "HQ 1080p30 Surround"
|
||||
assert presets.empfehlung("dvd", w)["preset"] == "HQ 576p25 Surround"
|
||||
|
||||
|
||||
# --- Der PC des Commanders: avx512f, 16 Kerne, RX 9070 XT -------------------
|
||||
|
||||
|
||||
def test_hardware_av1_wird_bevorzugt_wenn_die_familie_gemeldet_ist():
|
||||
"""Commander-Vorgabe: „wenn der Worker AV1 oder noch besseres kann, immer
|
||||
diesem empfehlen". QSV meldet AV1, also muss das AV1-Preset kommen."""
|
||||
w = [worker(encoders=["cpu-x265", "qsv", "qsv-av1"], simd="avx512f")]
|
||||
assert presets.empfehlung("uhd", w)["preset"] == "AV1 QSV 2160p 4K"
|
||||
|
||||
|
||||
def test_amd_bekommt_das_amd_preset_nicht_das_intel():
|
||||
"""Der Encoder heißt `vce`, das Preset heißt `VCN` — ohne diese Zuordnung
|
||||
liefe die RX 9070 XT des Commanders unter einem Intel-Namen."""
|
||||
w = [worker(encoders=["cpu-x265", "vce"], simd="avx512f")]
|
||||
assert presets.empfehlung("uhd", w)["preset"] == "H.265 VCN 2160p 4K"
|
||||
assert presets.empfehlung("bluray", w)["preset"] == "H.265 VCN 1080p"
|
||||
|
||||
|
||||
def test_hardware_grund_nennt_den_nachteil_auch():
|
||||
w = [worker(encoders=["vce"], simd="avx512f")]
|
||||
grund = presets.empfehlung("uhd", w)["grund"]
|
||||
assert "VCN" in grund
|
||||
assert "sauberer" in grund # ehrlich: Software hätte etwas mehr Qualität
|
||||
|
||||
|
||||
def test_starke_cpu_ohne_hardware_bekommt_h265():
|
||||
w = [worker(encoders=["cpu-x264", "cpu-x265"], simd="avx2")]
|
||||
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||
assert presets.empfehlung("bluray", w)["preset"] == "H.265 MKV 1080p30"
|
||||
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||
|
||||
|
||||
def test_dvd_bekommt_nie_ein_hardware_preset():
|
||||
"""HandBrake 1.6.1 hat keine Hardware-Presets in DVD-Auflösung — ein
|
||||
erfundenes „H.265 VCN 576p" würde die Kompression scheitern lassen."""
|
||||
w = [worker(encoders=["vce", "qsv", "nvenc"], simd="avx512f")]
|
||||
assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25"
|
||||
|
||||
|
||||
# --- Hardware, die KEIN passendes Preset hat --------------------------------
|
||||
|
||||
|
||||
def test_vaapi_faellt_auf_software_zurueck_statt_zu_raten():
|
||||
"""HandBrake 1.6.1 liefert kein VAAPI-Preset mit. Ein VAAPI-Worker darf
|
||||
deshalb keinen Hardware-Namen bekommen (AGENTS Regel D)."""
|
||||
w = [worker(encoders=["cpu-x265", "vaapi"], simd="avx2")]
|
||||
assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K"
|
||||
|
||||
|
||||
def test_mf_wird_nie_empfohlen_steht_aber_zur_wahl():
|
||||
"""Media-Foundation-Presets existieren, aber ob die Maschine sie nutzen
|
||||
kann, ist aus der Encoder-Liste nicht ablesbar — nicht empfehlen, nicht
|
||||
verstecken."""
|
||||
w = [worker(encoders=["cpu-x265"], simd="avx2")]
|
||||
assert "MF" not in presets.empfehlung("uhd", w)["preset"]
|
||||
namen = [a["name"] for a in presets.auswahl("uhd", w)]
|
||||
assert "H.265 MF 2160p 4K" in namen
|
||||
|
||||
|
||||
# --- Nur echte Namen, nie geratene ------------------------------------------
|
||||
|
||||
|
||||
def test_unbekannte_handbrake_version_bekommt_keine_erfundene_empfehlung():
|
||||
"""Meldet ein Worker nur Presets, die Rippy nicht kennt, wird ehrlich
|
||||
„selbst wählen" gesagt — statt einen Namen einzustellen, den dieses
|
||||
HandBrake ablehnt."""
|
||||
w = [worker(simd="avx2", presets_liste=["Irgendwas 4711p", "Noch was"])]
|
||||
e = presets.empfehlung("uhd", w)
|
||||
assert e["gefunden"] is False
|
||||
assert e["preset"] == ""
|
||||
assert "selbst" in e["grund"]
|
||||
|
||||
|
||||
def test_ohne_worker_greift_der_rueckfall():
|
||||
"""Kein Worker online (oder alter Worker-Stand ohne Preset-Meldung): die
|
||||
Auswahl darf nicht leer sein, sonst ist die Seite unbenutzbar."""
|
||||
assert presets.verfuegbare_presets([]) == list(presets.RUECKFALL_PRESETS)
|
||||
assert presets.uebersicht([])["quelle"] == "rueckfall"
|
||||
# ... und der Rückfall muss selbst brauchbar sein:
|
||||
e = presets.empfehlung("bluray", [])
|
||||
assert e["gefunden"] is True
|
||||
assert e["preset"] in presets.RUECKFALL_PRESETS
|
||||
|
||||
|
||||
def test_quelle_ist_worker_wenn_einer_meldet():
|
||||
assert presets.uebersicht([worker(simd="avx2")])["quelle"] == "worker"
|
||||
|
||||
|
||||
def test_verfuegbare_presets_vereinigt_ohne_doppelte():
|
||||
a = worker("a", presets_liste=["H.265 MKV 1080p30", "HQ 1080p30 Surround"])
|
||||
b = worker("b", presets_liste=["H.265 MKV 1080p30", "H.265 NVENC 1080p"])
|
||||
assert presets.verfuegbare_presets([a, b]) == [
|
||||
"H.265 MKV 1080p30", "HQ 1080p30 Surround", "H.265 NVENC 1080p",
|
||||
]
|
||||
|
||||
|
||||
# --- Auswahl-Listen: die 4K-Falle aus v3.12 darf nicht wiederkommen ---------
|
||||
|
||||
|
||||
def test_4k_auswahl_zeigt_2160p_presets_zuerst():
|
||||
w = [worker(simd="avx2")]
|
||||
liste = presets.auswahl("uhd", w)
|
||||
zuerst = [a["name"] for a in liste if not a["verkleinert"]]
|
||||
assert all("2160p" in n for n in zuerst)
|
||||
assert "H.265 MKV 2160p60 4K" in zuerst
|
||||
|
||||
|
||||
def test_4k_auswahl_kennzeichnet_das_verkleinern():
|
||||
"""Ein 1080p-Preset für eine 4K-Disc ist eine legitime Wahl, aber eine
|
||||
BEWUSSTE — in v3.12 passierte es aus Versehen und die 4K-Auflösung war weg."""
|
||||
w = [worker(simd="avx2")]
|
||||
verkleinert = [a["name"] for a in presets.auswahl("uhd", w) if a["verkleinert"]]
|
||||
assert "H.265 MKV 1080p30" in verkleinert
|
||||
assert all("1080p" in n for n in verkleinert)
|
||||
|
||||
|
||||
def test_dvd_auswahl_nimmt_beide_pal_und_ntsc_aufloesungen():
|
||||
w = [worker(simd="avx2")]
|
||||
namen = [a["name"] for a in presets.auswahl("dvd", w)]
|
||||
assert "H.265 MKV 576p25" in namen # PAL
|
||||
assert "H.265 MKV 480p30" in namen # NTSC
|
||||
assert "H.265 MKV 1080p30" not in namen
|
||||
|
||||
|
||||
def test_bluray_auswahl_enthaelt_kein_4k():
|
||||
w = [worker(simd="avx2")]
|
||||
namen = [a["name"] for a in presets.auswahl("bluray", w)]
|
||||
assert namen
|
||||
assert not any("2160p" in n for n in namen)
|
||||
|
||||
|
||||
# --- Erkennung der Maschinen-Stärke ----------------------------------------
|
||||
|
||||
|
||||
def test_unbekannte_simd_stufe_gilt_nicht_als_schnell_und_nicht_als_langsam():
|
||||
"""Ein Windows-Worker ohne die Erkennung aus v3.16 meldet `unbekannt`. Er
|
||||
darf nichts behaupten — aber auch keinen schnellen Worker ausbremsen."""
|
||||
assert presets.simd_schnell([worker(simd="unbekannt")]) is False
|
||||
assert presets.simd_schnell([worker(simd="unbekannt"), worker(simd="avx2")]) is True
|
||||
|
||||
|
||||
def test_hardware_kuerzel_nur_bei_echter_meldung():
|
||||
assert presets.hardware_kuerzel([worker(encoders=["cpu-x265"])]) == []
|
||||
assert presets.hardware_kuerzel([worker(encoders=["nvenc", "nvenc-av1"])]) == ["NVENC"]
|
||||
# Reihenfolge ist die Vorzugsreihenfolge, nicht die Meldereihenfolge
|
||||
assert presets.hardware_kuerzel([worker(encoders=["vce", "qsv"])]) == ["QSV", "VCN"]
|
||||
|
||||
|
||||
def test_uebersicht_liefert_alle_drei_disc_typen():
|
||||
u = presets.uebersicht([worker(simd="avx2")])
|
||||
assert set(u["typen"]) == {"uhd", "bluray", "dvd"}
|
||||
for eintrag in u["typen"].values():
|
||||
assert eintrag["empfehlung"]["preset"]
|
||||
assert eintrag["auswahl"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests für die Anfrage-Bremse.
|
||||
|
||||
Sie hatte bis zum 26.07.2026 keine — und dabei war sie seit Wochen die Ursache
|
||||
eines gemeldeten Fehlers („dieser Bereich wird oft neu geladen"): Das Limit lag
|
||||
mit 100/min UNTER dem, was Rippys eigenes Dashboard verursacht, und weil hinter
|
||||
dem nginx alle Clients dieselbe Adresse hatten, galt es auch noch gemeinsam.
|
||||
"""
|
||||
|
||||
import ratelimit
|
||||
|
||||
|
||||
def test_hinter_dem_proxy_zaehlt_der_echte_client():
|
||||
"""Der ganze Grund für den Fehler: ein Eimer für alle."""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.20") == "192.168.178.20"
|
||||
assert ratelimit.client_kennung("172.19.0.6", "192.168.178.99") == "192.168.178.99"
|
||||
# Zwei Clients hinter demselben Proxy sind zwei Eimer.
|
||||
assert (
|
||||
ratelimit.client_kennung("172.19.0.6", "192.168.178.20")
|
||||
!= ratelimit.client_kennung("172.19.0.6", "192.168.178.99")
|
||||
)
|
||||
|
||||
|
||||
def test_ohne_kopfzeile_gilt_der_direkte_absender():
|
||||
assert ratelimit.client_kennung("192.168.178.20", None) == "192.168.178.20"
|
||||
assert ratelimit.client_kennung("192.168.178.20", "") == "192.168.178.20"
|
||||
|
||||
|
||||
def test_erste_adresse_der_kette_gewinnt():
|
||||
"""X-Forwarded-For kann eine Liste sein — der Ursprung steht vorn."""
|
||||
assert ratelimit.client_kennung(
|
||||
"172.19.0.6", "192.168.178.20, 172.19.0.6"
|
||||
) == "192.168.178.20"
|
||||
|
||||
|
||||
def test_muell_in_der_kopfzeile_wird_ignoriert():
|
||||
"""Sonst könnte ein Tippfehler beliebig viele Eimer aufmachen."""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "nicht-ip") == "172.19.0.6"
|
||||
assert ratelimit.client_kennung("172.19.0.6", "<script>") == "172.19.0.6"
|
||||
|
||||
|
||||
def test_oeffentliche_adresse_in_der_kopfzeile_wird_nicht_geglaubt():
|
||||
"""Heimnetz-only: Was nicht aus dem privaten Netz kommt, zählt nicht.
|
||||
|
||||
Keine Härtung gegen Angreifer (dafür ist Rippy die falsche Stelle), aber die
|
||||
Bremse soll sich nicht mit einer beliebigen Kopfzeile aushebeln lassen.
|
||||
"""
|
||||
assert ratelimit.client_kennung("172.19.0.6", "8.8.8.8") == "172.19.0.6"
|
||||
|
||||
|
||||
def test_ohne_jede_angabe_bleibt_ein_eimer_uebrig():
|
||||
assert ratelimit.client_kennung("", None) == "unbekannt"
|
||||
|
||||
|
||||
def test_limit_greift_erst_nach_der_grenze():
|
||||
ratelimit.reset_rate_limit("test-a")
|
||||
for i in range(10):
|
||||
assert ratelimit.check_rate_limit("test-a", max_requests=10), f"Anfrage {i}"
|
||||
assert not ratelimit.check_rate_limit("test-a", max_requests=10)
|
||||
|
||||
|
||||
def test_eimer_sind_getrennt():
|
||||
ratelimit.reset_rate_limit("test-b")
|
||||
ratelimit.reset_rate_limit("test-c")
|
||||
for _ in range(5):
|
||||
ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||
assert not ratelimit.check_rate_limit("test-b", max_requests=5)
|
||||
assert ratelimit.check_rate_limit("test-c", max_requests=5)
|
||||
|
||||
|
||||
def test_grenze_deckt_die_eigene_last_ab():
|
||||
"""Die Bremse darf Rippy nicht selbst ausbremsen.
|
||||
|
||||
Nachgerechnet aus den Taktgebern im UI (Zahlen im Kopf von ratelimit.py):
|
||||
Dashboard 75/min + Log-Kasten 24/min + Laufwerke 12/min + Tray 12/min je
|
||||
Worker = 123/min für EINEN Tab. Mit 100 war das garantiert rot. Diese Prüfung
|
||||
hält fest, dass die Grenze mit Luft darüber liegt — wer den Wert senkt, muss
|
||||
hier vorbei.
|
||||
"""
|
||||
eigene_last_pro_tab = 75 + 24 + 12
|
||||
tray_pro_worker = 12
|
||||
assert ratelimit.MAX_REQUESTS_PER_MINUTE >= 2 * eigene_last_pro_tab + 3 * tray_pro_worker
|
||||
|
||||
|
||||
def test_meldung_wird_gedrosselt():
|
||||
"""Ein Amok-Skript darf nicht das Log-Fenster fluten."""
|
||||
ratelimit._letzte_meldung.pop("test-d", None)
|
||||
assert ratelimit.darf_melden("test-d", jetzt=1000.0)
|
||||
assert not ratelimit.darf_melden("test-d", jetzt=1001.0)
|
||||
assert not ratelimit.darf_melden("test-d", jetzt=1059.0)
|
||||
assert ratelimit.darf_melden("test-d", jetzt=1061.0)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests der Rohdaten-Suche — mit dem echten Fall, der sie nötig gemacht hat.
|
||||
|
||||
Job `95afdc89` (26.07.2026, an der laufenden Instanz gemessen): 79,6 GB
|
||||
Rohschnitt unter /app/media/rippy/<id>, Einstellung `workDir` leer, `can_retry`
|
||||
= false. Der Knopf „Neu komprimieren" fehlte, obwohl die Datei intakt war.
|
||||
"""
|
||||
|
||||
import rohdaten
|
||||
|
||||
JOB = "95afdc89-2426-4d44-829e-ad6ce1411905"
|
||||
|
||||
|
||||
def test_der_echte_fall_wird_gefunden():
|
||||
"""workDir ist LEER (so stand es live) und der Rohschnitt liegt trotzdem auf
|
||||
der NAS — weil beim Start eine Wahl nur für diesen Rip getroffen wurde."""
|
||||
orte = rohdaten.kandidaten(JOB, "", ["movies", "music", "rippy", "series"])
|
||||
assert f"/app/media/rippy/{JOB}" in orte
|
||||
# Der Container-Standard bleibt der erste Kandidat (schnellster Treffer)
|
||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||
|
||||
|
||||
def test_suche_liefert_nur_was_existiert():
|
||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["movies", "rippy"],
|
||||
isdir=lambda p: p in vorhanden,
|
||||
)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
def test_eingestelltes_arbeitsverzeichnis_kommt_vor_den_zielen():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media/rippy", ["movies", "rippy"])
|
||||
assert orte[1] == f"/app/media/rippy/{JOB}"
|
||||
# ... und taucht nicht doppelt auf, obwohl „rippy" auch Ablageziel ist
|
||||
assert orte.count(f"/app/media/rippy/{JOB}") == 1
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_ausserhalb_media_wird_ignoriert():
|
||||
"""Nur /app/media ist erlaubt (so entscheidet _arbeitsverzeichnis im
|
||||
Worker) — ein Pfad daneben darf hier nicht durchrutschen."""
|
||||
orte = rohdaten.kandidaten(JOB, "/etc", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
# Der Klassiker: ein Pfad, der nur mit dem Präfix ANFÄNGT
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media-boese", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_media_root_selbst_ist_erlaubt():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media", [])
|
||||
assert f"/app/media/{JOB}" in orte
|
||||
|
||||
|
||||
def test_ohne_job_id_nichts():
|
||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||
|
||||
|
||||
def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
||||
"""Ein toter CIFS-Mount lässt isdir mit OSError fliegen. Das darf die
|
||||
Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets)."""
|
||||
def isdir_kaputt(p):
|
||||
if "totes-nas" in p:
|
||||
raise OSError("Stale file handle")
|
||||
return p == f"/app/temp/raw/{JOB}"
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
||||
def listdir_kaputt(p):
|
||||
raise OSError("kein /app/media")
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
# --- Größe ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_groesse_zaehlt_nur_dateien():
|
||||
dateien = {
|
||||
f"/app/media/rippy/{JOB}/title_t00.mkv": 79604951639,
|
||||
f"/app/media/rippy/{JOB}/title_t01.mkv": 1000,
|
||||
}
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
[f"/app/media/rippy/{JOB}"],
|
||||
listdir=lambda p: ["title_t00.mkv", "title_t01.mkv", "unterordner"],
|
||||
isfile=lambda p: p in dateien,
|
||||
getsize=lambda p: dateien[p],
|
||||
)
|
||||
# Die echte Größe des Akira-Rohschnitts, plus eine zweite Datei
|
||||
assert bytes_gesamt == 79604952639
|
||||
assert anzahl == 2
|
||||
assert round(bytes_gesamt / 1024**3, 1) == 74.1
|
||||
|
||||
|
||||
def test_groesse_ohne_pfade_ist_null():
|
||||
assert rohdaten.groesse([], lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
assert rohdaten.groesse(None, lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
|
||||
|
||||
def test_groesse_ueberspringt_unlesbares():
|
||||
def getsize_kaputt(p):
|
||||
raise OSError("weg")
|
||||
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
|
||||
assert (bytes_gesamt, anzahl) == (0, 0)
|
||||
|
||||
|
||||
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
|
||||
#
|
||||
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
|
||||
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
|
||||
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
|
||||
|
||||
|
||||
class _Lauf:
|
||||
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
|
||||
|
||||
def __init__(self, rc=0, wirf=None):
|
||||
self.rc, self.wirf, self.cmd = rc, wirf, None
|
||||
|
||||
def __call__(self, cmd, **kwargs):
|
||||
self.cmd = cmd
|
||||
if self.wirf:
|
||||
raise self.wirf
|
||||
|
||||
class E:
|
||||
returncode = self.rc
|
||||
return E()
|
||||
|
||||
|
||||
def test_verzeichnis_da_nutzt_timeout_und_ls():
|
||||
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
|
||||
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
|
||||
lauf = _Lauf(rc=0)
|
||||
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
|
||||
assert lauf.cmd[0] == "timeout"
|
||||
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
|
||||
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
|
||||
|
||||
|
||||
def test_verzeichnis_da_nicht_vorhanden():
|
||||
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
|
||||
|
||||
|
||||
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
|
||||
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
|
||||
kann, ist fuer einen Rip ohnehin unbrauchbar."""
|
||||
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
|
||||
|
||||
|
||||
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
|
||||
import subprocess as sp
|
||||
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
|
||||
assert rohdaten.verzeichnis_da(
|
||||
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
|
||||
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
|
||||
|
||||
|
||||
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
||||
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
|
||||
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
|
||||
def laufen(cmd, **kwargs):
|
||||
pfad = cmd[-1]
|
||||
|
||||
class E:
|
||||
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
|
||||
return E()
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||
)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------
|
||||
|
||||
|
||||
def test_pruefen_unterscheidet_drei_faelle():
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da"
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg"
|
||||
# 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen
|
||||
assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar"
|
||||
assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar"
|
||||
assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg"
|
||||
|
||||
|
||||
def test_verzeichnis_da_bleibt_streng():
|
||||
"""Wer Ja/Nein braucht, bekommt im Zweifel Nein."""
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True
|
||||
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False
|
||||
|
||||
|
||||
def test_suche_mit_status_meldet_ungepruefte_orte():
|
||||
"""Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer.
|
||||
Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten."""
|
||||
def pruefe(pfad):
|
||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
||||
assert e == {"pfade": [], "unklar": True}
|
||||
|
||||
|
||||
def test_suche_mit_status_ohne_zweifel():
|
||||
def pruefe(pfad):
|
||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||
|
||||
|
||||
def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
||||
"""Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt."""
|
||||
def pruefe(pfad):
|
||||
if pfad == f"/app/media/rippy/{JOB}":
|
||||
return "da"
|
||||
return "unklar" if "totes-nas" in pfad else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||
assert e["unklar"] is True
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tests für die TMDB-Key-Erkennung (v3-Schlüssel vs. v4-Token).
|
||||
|
||||
Befund 24.07.: Der Client konnte nur v4-Bearer — mit dem üblichen 32-Hex-
|
||||
v3-Key war jede Anfrage 401 und TMDB fiel still aus (nur OMDb-Treffer).
|
||||
"""
|
||||
|
||||
from clients.tmdb import ist_v4_token
|
||||
|
||||
|
||||
def test_v4_token_wird_erkannt():
|
||||
assert ist_v4_token("eyJhbGciOiJIUzI1NiJ9.irgendwas.signatur") is True
|
||||
|
||||
|
||||
def test_v3_hex_key_ist_kein_v4_token():
|
||||
assert ist_v4_token("842abc0123456789842abc0123456789") is False
|
||||
|
||||
|
||||
def test_leer_ist_kein_token():
|
||||
assert ist_v4_token("") is False
|
||||
assert ist_v4_token(None) is False
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM postgres:16
|
||||
|
||||
COPY init.sql /docker-entrypoint-initdb.d/
|
||||
|
||||
EXPOSE 5432
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE DATABASE rippy;
|
||||
CREATE USER rippy WITH PASSWORD 'rippy123';
|
||||
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
COPY redis.conf /usr/local/etc/redis/redis.conf
|
||||
|
||||
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
@@ -1,6 +0,0 @@
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
loglevel notice
|
||||
databases 16
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Rippy — Automatisches Ripping-System</title>
|
||||
</head>
|
||||
|
||||
@@ -14,9 +14,24 @@ server {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# ⚠️ OHNE DIESE ZEILEN TEILEN SICH ALLE CLIENTS EIN RATE-LIMIT
|
||||
#
|
||||
# Die API begrenzt Anfragen pro Client-IP (ratelimit.py). Ohne
|
||||
# weitergegebene Adresse sah sie als Absender immer DIESEN Container —
|
||||
# Browser, zweiter Tab und der Windows-Tray landeten also in einem
|
||||
# gemeinsamen Eimer. Gemessen am 26.07.2026: 812 von 876 Anfragen kamen
|
||||
# scheinbar von 172.19.0.6, und das UI bekam laufend HTTP 429.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
# Der nginx-Default ist 1 MB. Eine echte KEYDB.cfg ist deutlich groesser,
|
||||
# der Upload ueber POST /api/system/keydb wuerde also schon hier mit
|
||||
# 413 abgewiesen — die API bekaeme die Anfrage nie zu sehen und das UI
|
||||
# haette keinen detail-Text, den es anzeigen koennte. 64m entspricht dem
|
||||
# Limit MAX_KEYDB_BYTES in makemkv_daten.py.
|
||||
client_max_body_size 64m;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
@@ -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 |
+64
-58
@@ -1,82 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-react'
|
||||
import { useDarkMode } from './context/ThemeContext'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import MetadataPreview from './pages/MetadataPreview'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
|
||||
type Page = 'dashboard' | 'metadata' | 'settings' | 'logs'
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
const { theme, toggleTheme } = useDarkMode()
|
||||
export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||
|
||||
const navItems = [
|
||||
useEffect(() => {
|
||||
api.get('/setup')
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
}, [])
|
||||
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'metadata', label: 'Metadaten', icon: Disc },
|
||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300">
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
|
||||
<Disc size={28} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||
{/* Top Header Navigation Bar */}
|
||||
<header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
|
||||
<span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
|
||||
RIPPY
|
||||
</span>
|
||||
<span className="px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||
CINEMA OS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200"
|
||||
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
<nav className="flex-1 px-3 py-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
{/* Central Nav Tabs */}
|
||||
<nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = currentPage === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setPage(item.id as Page)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
page === item.id
|
||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
||||
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
||||
</aside>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span>ONLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{page === 'dashboard' && <Dashboard />}
|
||||
{page === 'metadata' && <MetadataPreview />}
|
||||
{page === 'logs' && <LogsPage />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
{/* Main Content Viewport */}
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -7,6 +9,9 @@ interface ConfirmDialogProps {
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
// Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten,
|
||||
// die beim Entfernen liegen bleiben, samt Wahl „mitlöschen".
|
||||
extra?: ReactNode
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
type?: 'warning' | 'info' | 'success'
|
||||
@@ -18,66 +23,57 @@ export default function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
extra,
|
||||
confirmText = 'Bestätigen',
|
||||
cancelText = 'Abbrechen',
|
||||
type = 'info',
|
||||
}: ConfirmDialogProps) {
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'warning': return <span className="text-4xl">⚠️</span>
|
||||
case 'success': return <span className="text-4xl">✅</span>
|
||||
default: return <span className="text-4xl">🤔</span>
|
||||
case 'warning':
|
||||
return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||
case 'success':
|
||||
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
|
||||
default:
|
||||
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
}
|
||||
}
|
||||
|
||||
const getConfirmColor = () => {
|
||||
const getConfirmVariant = () => {
|
||||
switch (type) {
|
||||
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30'
|
||||
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30'
|
||||
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30'
|
||||
case 'warning':
|
||||
return 'amber'
|
||||
case 'success':
|
||||
return 'primary'
|
||||
default:
|
||||
return 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
||||
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
{/* Header */}
|
||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
|
||||
<div className="flex items-start gap-4">
|
||||
{getIcon()}
|
||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-6">
|
||||
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{message}
|
||||
</p>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
|
||||
>
|
||||
</Button>
|
||||
<Button variant={getConfirmVariant()} onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,107 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
import { TypeBadge } from './ui/Badge'
|
||||
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||
import MetadataKorrektur from './MetadataKorrektur'
|
||||
|
||||
interface DiscInfo {
|
||||
title: string
|
||||
year?: number
|
||||
disc_type: string
|
||||
confidence: number
|
||||
metadata?: {
|
||||
poster_path?: string
|
||||
overview?: string
|
||||
type?: string
|
||||
source?: string
|
||||
}
|
||||
bereits_gerippt?: {
|
||||
job_id: string
|
||||
title?: string
|
||||
finished_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
type: 'cd' | 'dvd' | 'bluray' | 'unknown'
|
||||
type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||
path: string
|
||||
status: 'empty' | 'ready' | 'ripping'
|
||||
serial?: string
|
||||
model?: string
|
||||
disc?: DiscInfo
|
||||
}
|
||||
|
||||
function posterUrl(disc?: DiscInfo): string | null {
|
||||
const p = disc?.metadata?.poster_path
|
||||
if (!p) return null
|
||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||
}
|
||||
|
||||
function typLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'cd': return 'Audio CD'
|
||||
case 'dvd': return 'DVD'
|
||||
case 'bluray': return 'Blu-ray'
|
||||
case 'uhd': return '4K UHD'
|
||||
default: return 'Unbekannt'
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeviceDiscovery() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const refreshDevices = async () => {
|
||||
const refreshDevices = async (manuell = false) => {
|
||||
if (manuell) setRefreshing(true)
|
||||
try {
|
||||
const response = await api.get('/devices')
|
||||
setDevices(response.data)
|
||||
if (manuell) toast('success', 'Laufwerke aktualisiert')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Geräte:', error)
|
||||
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (manuell) setTimeout(() => setRefreshing(false), 400)
|
||||
}
|
||||
}
|
||||
|
||||
const startRip = async (device: Device) => {
|
||||
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
|
||||
setActionBusy(true)
|
||||
setActionFeedback(null)
|
||||
try {
|
||||
const response = await api.post('/jobs', { device_path: device.path })
|
||||
const response = await api.post('/jobs', {
|
||||
device_path: device.path,
|
||||
...(targetDir ? { target_dir: targetDir } : {}),
|
||||
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
||||
// Sprachauswahl dieses Rips (leer = alles behalten)
|
||||
...(optionen?.audioSprachen?.length ? { audio_sprachen: optionen.audioSprachen } : {}),
|
||||
...(optionen?.untertitelSprachen?.length
|
||||
? { untertitel_sprachen: optionen.untertitelSprachen } : {}),
|
||||
})
|
||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||
} 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 {
|
||||
setActionBusy(false)
|
||||
}
|
||||
@@ -51,9 +113,12 @@ export default function DeviceDiscovery() {
|
||||
try {
|
||||
await api.post(`/devices/${device.id}/eject`)
|
||||
setActionFeedback('✓ Disc ausgeworfen')
|
||||
toast('success', 'Disc ausgeworfen')
|
||||
refreshDevices()
|
||||
} catch (error: any) {
|
||||
setActionFeedback(`✗ ${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`)
|
||||
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
|
||||
setActionFeedback(`✗ ${detail}`)
|
||||
toast('error', detail)
|
||||
} finally {
|
||||
setActionBusy(false)
|
||||
}
|
||||
@@ -65,162 +130,194 @@ export default function DeviceDiscovery() {
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
|
||||
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
|
||||
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
|
||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
const QUELLEN_LABEL: Record<string, string> = {
|
||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
||||
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'empty': return <AlertCircle size={16} />
|
||||
case 'ready': return <CheckCircle size={16} />
|
||||
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
|
||||
default: return <AlertCircle size={16} />
|
||||
case 'empty':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Keine Disc</span>
|
||||
case 'ready':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"><CheckCircle size={13} /> Bereit</span>
|
||||
case 'ripping':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-sky-500/30 animate-pulse"><RefreshCw size={13} className="animate-spin" /> Rippt</span>
|
||||
default:
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Unbekannt</span>
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="animate-spin text-indigo-600" size={24} />
|
||||
<RefreshCw className="animate-spin text-amber-500" size={24} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshDevices}
|
||||
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
|
||||
title="Geräte neu laden"
|
||||
>
|
||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<CardTitle>Laufwerke</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
|
||||
<RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<div className="p-6 transition-colors duration-300">
|
||||
{devices.length === 0 ? (
|
||||
<div className="text-center py-12 transition-colors duration-300">
|
||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
|
||||
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
||||
<CardContent>
|
||||
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||
<div
|
||||
key={`disc-${device.id}`}
|
||||
className="mb-6 rounded-2xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 flex shadow-xl shadow-amber-500/5 backdrop-blur-md"
|
||||
>
|
||||
{posterUrl(device.disc) && (
|
||||
<img
|
||||
src={posterUrl(device.disc)!}
|
||||
alt={device.disc!.title}
|
||||
className="w-32 object-cover flex-shrink-0 border-r border-slate-800"
|
||||
/>
|
||||
)}
|
||||
<div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
|
||||
Im Laufwerk erkannt
|
||||
</p>
|
||||
<h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
|
||||
{device.disc!.title}
|
||||
{device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<TypeBadge type={device.type} />
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
||||
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{device.disc!.metadata?.overview && (
|
||||
<p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
|
||||
{device.disc!.metadata.overview}
|
||||
</p>
|
||||
)}
|
||||
{device.disc!.bereits_gerippt && (
|
||||
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
|
||||
<AlertCircle size={13} className="flex-shrink-0" />
|
||||
Diese Disc wurde bereits gerippt
|
||||
{device.disc!.bereits_gerippt.finished_at
|
||||
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
||||
: ''} — nochmal rippen ist möglich.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
|
||||
Rippen starten
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
|
||||
Nicht korrekt?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-4 bg-slate-100 dark:bg-slate-800 text-slate-400">
|
||||
<HardDrive size={32} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
|
||||
<p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
|
||||
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
<div className="space-y-3">
|
||||
{devices.map(device => (
|
||||
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<div key={device.id} className="rounded-xl p-4 border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}>
|
||||
<Disc size={24} />
|
||||
<div className="p-3 rounded-xl bg-slate-200/60 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||
<Disc size={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{device.name || 'Unbekanntes Laufwerk'}
|
||||
</h3>
|
||||
{device.status === 'ripping' && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
Rippt
|
||||
</span>
|
||||
)}
|
||||
<TypeBadge type={device.type} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||
{device.path}
|
||||
</span>
|
||||
{device.serial && (
|
||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||
{device.serial}
|
||||
</span>
|
||||
)}
|
||||
{device.model && (
|
||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||
{device.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}>
|
||||
{getStatusIcon(device.status)}
|
||||
{device.status === 'empty' ? 'Keine Disc' :
|
||||
device.status === 'ready' ? 'Bereit' :
|
||||
'Rippt'}
|
||||
</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Typ: {device.type.toUpperCase()}
|
||||
</span>
|
||||
<div className="mt-3">
|
||||
{getStatusBadge(device.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setExpandedId(expandedId === device.id ? null : device.id)
|
||||
setActionFeedback(null)
|
||||
}}
|
||||
className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}
|
||||
>
|
||||
{expandedId === device.id ? 'Schließen' : 'Details'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expandedId === device.id && (
|
||||
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div className="mt-4 pt-4 border-t border-slate-200/80 dark:border-slate-800 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gerätepfad</span>
|
||||
<span className={`font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.path}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Modell</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>{device.model || 'unbekannt'}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Seriennummer</span>
|
||||
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
|
||||
{device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'}
|
||||
<span className="text-slate-500 dark:text-slate-400">Gerätepfad</span>
|
||||
<span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Modell</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
|
||||
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Disc</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onClick={() => startRip(device)}
|
||||
<Button
|
||||
variant="amber"
|
||||
size="sm"
|
||||
onClick={() => setModalDevice(device)}
|
||||
disabled={actionBusy || device.status !== 'ready'}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Rippen starten
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => ejectDisc(device)}
|
||||
disabled={actionBusy || device.status !== 'ready'}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${theme === 'dark' ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Auswerfen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{actionFeedback && (
|
||||
<p className={`text-sm ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||
<p className={`text-sm font-medium ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||
{actionFeedback}
|
||||
</p>
|
||||
)}
|
||||
@@ -230,7 +327,28 @@ export default function DeviceDiscovery() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<RipTargetModal
|
||||
isOpen={modalDevice !== null}
|
||||
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||
discTitle={modalDevice?.disc?.title}
|
||||
deviceId={modalDevice?.id}
|
||||
onClose={() => setModalDevice(null)}
|
||||
onSave={(target, optionen) => {
|
||||
if (modalDevice) {
|
||||
startRip(modalDevice, target.path, optionen)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<MetadataKorrektur
|
||||
devicePath={korrekturDevice?.path || ''}
|
||||
vorschlag={korrekturDevice?.disc?.title}
|
||||
isOpen={korrekturDevice !== null}
|
||||
onClose={() => setKorrekturDevice(null)}
|
||||
onUebernommen={refreshDevices}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input, Select } from './ui/Input'
|
||||
|
||||
interface WorkerInfo {
|
||||
name: string
|
||||
encoders: string[]
|
||||
online?: boolean
|
||||
info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string }
|
||||
}
|
||||
|
||||
interface GeraetInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface PlatzInfo {
|
||||
name: string
|
||||
frei_gb: number
|
||||
gesamt_gb: number
|
||||
}
|
||||
|
||||
const ENCODER_LABELS: Record<string, string> = {
|
||||
'cpu-x264': 'CPU · H.264 (x264)',
|
||||
'cpu-x265': 'CPU · H.265 (x265)',
|
||||
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
|
||||
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
||||
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
||||
}
|
||||
|
||||
// Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray
|
||||
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||||
const PLATZ_WARNUNG_GB = 60
|
||||
|
||||
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
|
||||
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
|
||||
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
|
||||
interface PresetUebersicht {
|
||||
quelle: 'worker' | 'rueckfall'
|
||||
hardware: string[]
|
||||
typen: Record<'uhd' | 'bluray' | 'dvd', {
|
||||
empfehlung: { preset: string, grund: string, gefunden: boolean }
|
||||
}>
|
||||
}
|
||||
|
||||
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
|
||||
function presetsFuer(uebersicht: PresetUebersicht | null) {
|
||||
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
|
||||
const e = uebersicht?.typen?.[typ]?.empfehlung
|
||||
return e?.gefunden ? e.preset : ''
|
||||
}
|
||||
const uhd = nimm('uhd')
|
||||
const bluray = nimm('bluray')
|
||||
const dvd = nimm('dvd')
|
||||
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
|
||||
// statt dass HandBrake später ein leeres --preset bekommt.
|
||||
return {
|
||||
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
|
||||
...(dvd ? { transcodePresetDvd: dvd } : {}),
|
||||
...(uhd ? { transcodePresetUhd: uhd } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
const [tmdbKey, setTmdbKey] = useState('')
|
||||
const [omdbKey, setOmdbKey] = useState('')
|
||||
// MakeMKV-Beta-Key: Lizenz fuer die SOFTWARE (Blu-ray), nicht der
|
||||
// Disc-Schluessel einer einzelnen 4K-Disc.
|
||||
const [makemkvKey, setMakemkvKey] = useState('')
|
||||
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||||
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||
const [geladen, setGeladen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
|
||||
|
||||
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
|
||||
// läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft
|
||||
// „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
|
||||
useEffect(() => {
|
||||
let laeuft = true
|
||||
const laden = async () => {
|
||||
const [caps, devs, info, pres] = await Promise.allSettled([
|
||||
api.get('/capabilities'),
|
||||
api.get('/devices'),
|
||||
api.get('/system/info'),
|
||||
api.get('/presets'),
|
||||
])
|
||||
if (!laeuft) return
|
||||
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
||||
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
||||
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
||||
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
|
||||
setGeladen(true)
|
||||
}
|
||||
laden()
|
||||
const takt = setInterval(laden, 5000)
|
||||
return () => { laeuft = false; clearInterval(takt) }
|
||||
}, [])
|
||||
|
||||
const schwach = schwacheEncoderCpu(workers)
|
||||
const presets = presetsFuer(presetInfo)
|
||||
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
||||
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
||||
|
||||
const abschliessen = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setKeyWarnung(null)
|
||||
try {
|
||||
const bestehende = (await api.get('/settings')).data || {}
|
||||
await api.post('/settings', {
|
||||
...bestehende,
|
||||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||||
mediaServer,
|
||||
transcodeEnabled,
|
||||
...presets,
|
||||
})
|
||||
// Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
|
||||
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
|
||||
// sofort auf, statt erst beim ersten Rip als „Unknown Disc".
|
||||
if (tmdbKey.trim() || omdbKey.trim()) {
|
||||
try {
|
||||
const s = (await api.get('/metadata/status')).data || {}
|
||||
const kaputt = Object.entries(s)
|
||||
.filter(([, v]) => v === 'fehler')
|
||||
.map(([k]) => k.toUpperCase())
|
||||
if (kaputt.length > 0) {
|
||||
setKeyWarnung(
|
||||
`${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein `
|
||||
+ 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann '
|
||||
+ 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.'
|
||||
)
|
||||
setSaving(false)
|
||||
return // nicht wegklicken, damit die Warnung gelesen wird
|
||||
}
|
||||
} catch {
|
||||
// Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
|
||||
}
|
||||
}
|
||||
await api.post('/setup/complete')
|
||||
onDone()
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const trotzdemWeiter = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post('/setup/complete')
|
||||
onDone()
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen — läuft die API?')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
|
||||
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Disc size={32} className="animate-disc-spin" />
|
||||
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
||||
</div>
|
||||
<p className="opacity-90">
|
||||
Einmalige Einrichtung — dauert keine zwei Minuten. Du kannst nichts kaputt
|
||||
machen: alles ist später unter Einstellungen änderbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
|
||||
</h2>
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
|
||||
<Zeile
|
||||
titel="Laufwerk"
|
||||
geladen={geladen}
|
||||
gut={geraete.length > 0}
|
||||
gutText={geraete.map(g => g.name).join(', ')}
|
||||
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
|
||||
/>
|
||||
<Zeile
|
||||
titel="Encoding-Worker"
|
||||
geladen={geladen}
|
||||
gut={encoderWorker.length > 0}
|
||||
gutText={encoderWorker.map(w => {
|
||||
const kerne = w.info?.cpu_kerne
|
||||
const simd = w.info?.cpu_simd
|
||||
const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : '']
|
||||
.filter(Boolean).join(', ')
|
||||
return zusatz ? `${w.name} (${zusatz})` : w.name
|
||||
}).join(' · ')}
|
||||
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht."
|
||||
/>
|
||||
<Zeile
|
||||
titel="Freier Platz"
|
||||
geladen={geladen}
|
||||
gut={platz === null || platz >= PLATZ_WARNUNG_GB}
|
||||
gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`}
|
||||
schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Metadaten ---------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys,
|
||||
keine Passwoerter — und einen Tippfehler sieht man in Punkten
|
||||
nicht. Geprueft wird direkt nach dem Speichern. */}
|
||||
<Input
|
||||
label="TMDB API-Key (empfohlen)"
|
||||
value={tmdbKey}
|
||||
onChange={e => setTmdbKey(e.target.value)}
|
||||
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
|
||||
/>
|
||||
<Input
|
||||
label="OMDb API-Key (optional, zweite Quelle)"
|
||||
value={omdbKey}
|
||||
onChange={e => setOmdbKey(e.target.value)}
|
||||
placeholder="Kostenlos auf omdbapi.com"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
||||
Beide Keys werden direkt nach dem Speichern geprüft.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- MakeMKV-Beta-Key --------------------------------------- */}
|
||||
{/*
|
||||
Neu 26.07.2026, bei der Weitergabe-Prüfung aufgefallen: Der Key war
|
||||
in der README, in der .env und in den Einstellungen zu finden — nur
|
||||
NICHT hier. Ein Fremder installiert also, legt eine Blu-ray ein und
|
||||
bekommt irgendwann einen Fehlschlag, ohne dass ihn beim Einrichten
|
||||
jemand darauf hingewiesen hätte. Das ist die wahrscheinlichste
|
||||
Stolperstelle einer frischen Installation.
|
||||
*/}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<KeyRound size={18} className="text-amber-500" /> MakeMKV-Beta-Key
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="Beta-Key (für Blu-ray; DVDs gehen ohne)"
|
||||
value={makemkvKey}
|
||||
onChange={e => setMakemkvKey(e.target.value)}
|
||||
placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
MakeMKV ist für <strong>DVDs dauerhaft kostenlos</strong>. Für
|
||||
<strong> Blu-ray und 4K</strong> braucht es den Beta-Key; ohne ihn läuft
|
||||
eine 30-Tage-Probezeit, danach bricht jeder Blu-ray-Rip ab. Der Key ist
|
||||
kostenlos, wechselt aber etwa monatlich — nachpflegen unter
|
||||
Einstellungen → System, das wirkt sofort ab dem nächsten Rip, ohne
|
||||
Neubau.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Nicht verwechseln mit dem <strong>Disc-Schlüssel</strong> einer einzelnen
|
||||
4K-Disc — das ist eine andere Sache und steht unter Einstellungen → System.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Media-Server ------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
||||
</h2>
|
||||
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
|
||||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
|
||||
zusätzlich movie.nfo + poster.jpg dazu.
|
||||
</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-xl text-sm font-medium border transition-all ${
|
||||
mediaServer === o.wert
|
||||
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---- Verarbeitung ------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
||||
</h2>
|
||||
|
||||
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={transcodeEnabled}
|
||||
onChange={e => setTranscodeEnabled(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
|
||||
/>
|
||||
<span>
|
||||
Nach dem Rip auf Arbeitsgröße komprimieren
|
||||
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||||
Empfohlen — ohne Kompression braucht jede Blu-ray rund 40 GB.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{transcodeEnabled && (
|
||||
<div className={`rounded-xl p-4 border ${
|
||||
schwach
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
|
||||
}`}>
|
||||
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN
|
||||
Rechenleistung. Vorher stand hier H.265 als Standard - auf
|
||||
einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und
|
||||
genau das ist am 25.07.2026 passiert. */}
|
||||
{schwach ? (
|
||||
<>
|
||||
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
|
||||
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
|
||||
</p>
|
||||
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
|
||||
Keiner deiner Worker kann AVX2 — die Vektorbefehle, von denen H.265
|
||||
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
|
||||
{' '}dauern. Deshalb wird eingestellt:
|
||||
</p>
|
||||
</>
|
||||
) : presetInfo && presetInfo.hardware.length > 0 ? (
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<li>
|
||||
<strong>4K-UHD:</strong>{' '}
|
||||
{presets.transcodePresetUhd === PRESET_KEINE
|
||||
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
||||
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Blu-ray:</strong>{' '}
|
||||
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||
</li>
|
||||
<li>
|
||||
<strong>DVD:</strong>{' '}
|
||||
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||||
</li>
|
||||
</ul>
|
||||
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
Warum: {presetInfo.typen.uhd.empfehlung.grund}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{encoderWorker.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
|
||||
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||
e.startsWith('cpu')
|
||||
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{keyWarnung && (
|
||||
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
|
||||
Key korrigieren
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
|
||||
Trotzdem fertigstellen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="amber"
|
||||
size="lg"
|
||||
onClick={abschliessen}
|
||||
disabled={saving}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
|
||||
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst
|
||||
* bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */
|
||||
function Zeile({ titel, geladen, gut, gutText, schlechtText }: {
|
||||
titel: string
|
||||
geladen: boolean
|
||||
gut: boolean
|
||||
gutText: string
|
||||
schlechtText: string
|
||||
}) {
|
||||
if (!geladen) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} — wird geprüft…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<span className="font-medium">{titel}:</span>{' '}
|
||||
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
|
||||
</p>
|
||||
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Disc, Sparkles, CheckCircle, Shield, Zap, Tv, HardDrive } from 'lucide-react'
|
||||
import { RadialProgress } from './ui/RadialProgress'
|
||||
import { TypeBadge, StatusBadge } from './ui/Badge'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
interface JobMeta {
|
||||
year?: number
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
genres?: string[]
|
||||
runtime?: number
|
||||
type?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: string
|
||||
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
|
||||
device: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
progress: number
|
||||
title?: string
|
||||
can_retry?: boolean
|
||||
meta?: JobMeta | null
|
||||
}
|
||||
|
||||
function posterUrl(meta?: JobMeta | null): string | null {
|
||||
const p = meta?.poster_path
|
||||
if (!p) return null
|
||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w500${p}`
|
||||
}
|
||||
|
||||
interface HeroCinemaBannerProps {
|
||||
aktiverJob?: Job | null
|
||||
completedJobs?: Job[]
|
||||
onDetailClick?: (jobId: string) => void
|
||||
onCancelClick?: (jobId: string) => void
|
||||
}
|
||||
|
||||
export function HeroCinemaBanner({
|
||||
aktiverJob,
|
||||
completedJobs = [],
|
||||
onDetailClick,
|
||||
onCancelClick,
|
||||
}: HeroCinemaBannerProps) {
|
||||
// Fall 1: Aktiver Job (Rippt oder Komprimiert live)
|
||||
if (aktiverJob) {
|
||||
const isTranscoding = aktiverJob.status === 'transcoding'
|
||||
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
|
||||
const poster = posterUrl(aktiverJob.meta)
|
||||
|
||||
return (
|
||||
<div className="relative rounded-3xl overflow-hidden border-2 border-amber-500/50 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative flex-shrink-0">
|
||||
{poster ? (
|
||||
<div className="relative">
|
||||
<img src={poster} alt="" className="w-28 h-40 rounded-2xl object-cover shadow-2xl border-2 border-amber-500/40" />
|
||||
<div className="absolute -bottom-3 -right-3 w-14 h-14 rounded-full disc-texture shadow-xl flex items-center justify-center animate-disc-spin border border-amber-400">
|
||||
<Disc size={20} className="text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-28 h-28 rounded-full disc-texture shadow-2xl flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-500/60">
|
||||
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center sm:text-left">
|
||||
<div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap">
|
||||
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md">
|
||||
NOW RIPPING
|
||||
</span>
|
||||
<TypeBadge type={aktiverJob.type} />
|
||||
<StatusBadge status={aktiverJob.status} />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
|
||||
{aktiverJob.title || 'Aktiver Ripping-Prozess'}
|
||||
</h2>
|
||||
|
||||
<p className="text-xs text-slate-400 font-mono">
|
||||
Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong> · {statusText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
|
||||
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" />
|
||||
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
<Button variant="amber" size="md" onClick={() => onDetailClick && onDetailClick(aktiverJob.id)}>
|
||||
<Sparkles size={16} /> Details
|
||||
</Button>
|
||||
<Button variant="danger" size="md" onClick={() => onCancelClick && onCancelClick(aktiverJob.id)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fall 2: Kein aktiver Job -> 5-Panel Kachel-Galerie VOLLSTÄNDIG ausfüllen (Zero "Löcher"!)
|
||||
// Wir füllen freie Slots mit informativen Cinema-Engine-Showcase-Kacheln auf!
|
||||
const realMovieTiles = completedJobs.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-[#0e1322] shadow-[0_0_40px_-5px_rgba(245,158,11,0.18)] mb-8 p-3">
|
||||
{/* Top Subtle Amber Line */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-amber-500/40 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
|
||||
{/* Echte Medien-Kacheln aus der API */}
|
||||
{realMovieTiles.map((job) => {
|
||||
const poster = posterUrl(job.meta)
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
onClick={() => onDetailClick && onDetailClick(job.id)}
|
||||
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
|
||||
>
|
||||
{poster ? (
|
||||
<img
|
||||
src={poster}
|
||||
alt={job.title || ''}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-slate-950 flex flex-col items-center justify-center p-3 text-center">
|
||||
<Disc size={32} className="text-amber-400 mb-2" />
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">{job.type}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
|
||||
|
||||
<div className="relative z-10 space-y-1">
|
||||
<TypeBadge type={job.type} />
|
||||
<h4 className="text-xs font-bold text-slate-100 truncate group-hover:text-amber-400 transition-colors">
|
||||
{job.title || 'Unbekannt'}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
|
||||
{realMovieTiles.length < 5 && (
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-[#131929] to-slate-950 border-2 border-amber-500/40 flex items-center justify-center p-4 shadow-inner group">
|
||||
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-400/60">
|
||||
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_16px_rgba(245,158,11,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-2.5 px-3 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-widest bg-amber-500 text-slate-950 shadow-md">
|
||||
DISC ENGINE
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Engine Tile: Drive Status */}
|
||||
{realMovieTiles.length < 4 && (
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-indigo-950/80 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold">
|
||||
<CheckCircle size={14} /> DRIVE READY
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-100">Automatische Erkennung</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Lege eine Disc ein — Rippy rippt sofort los.</p>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-amber-400 font-bold">
|
||||
4K UHD · BLU-RAY · DVD · CD
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Engine Tile: Media-Server Sync */}
|
||||
{realMovieTiles.length < 3 && (
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold">
|
||||
<Tv size={14} /> MEDIA SERVER
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-100">Auto-NFO & Poster</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Jellyfin, Plex & Kodi kompatibel.</p>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-slate-400">
|
||||
Auto-Scan nach Rip
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Engine Tile: Lossless Assurance */}
|
||||
{realMovieTiles.length < 2 && (
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||
<div className="flex items-center gap-2 text-indigo-400 text-xs font-bold">
|
||||
<Shield size={14} /> MakeMKV
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-slate-100">Lossless 1:1</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Alle Tonspuren & Subtitles erhalten.</p>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-slate-400">
|
||||
x265 HandBrake Kompression
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { StatusBadge } from './ui/Badge'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
interface JobDatei {
|
||||
name: string
|
||||
size_mb: number | null
|
||||
}
|
||||
|
||||
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
|
||||
const [detail, setDetail] = useState<JobDetail | null>(null)
|
||||
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
setDetail(null)
|
||||
setDateien(null)
|
||||
return
|
||||
}
|
||||
setLaedt(true)
|
||||
api.get(`/jobs/${jobId}/detail`)
|
||||
.then(r => {
|
||||
setDetail(r.data)
|
||||
if (r.data?.status === 'completed') {
|
||||
api.get(`/jobs/${jobId}/files`)
|
||||
.then(f => setDateien(f.data.files))
|
||||
.catch(() => setDateien(null))
|
||||
}
|
||||
})
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLaedt(false))
|
||||
}, [jobId])
|
||||
|
||||
if (!jobId) return null
|
||||
|
||||
const meta = detail?.meta
|
||||
const poster = posterUrl(meta)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={!!jobId}
|
||||
onClose={onClose}
|
||||
title={detail?.title || 'Job-Details'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{laedt ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin text-amber-500" size={28} />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
|
||||
Job nicht gefunden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
||||
<div className="flex gap-5">
|
||||
{poster ? (
|
||||
<img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
|
||||
) : (
|
||||
<div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{detail.title || 'Unbekannt'}
|
||||
{meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
|
||||
</h3>
|
||||
<StatusBadge status={detail.status} />
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-slate-500 dark: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.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{meta?.overview ? (
|
||||
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
|
||||
{meta.overview}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
|
||||
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job-Fakten Grid */}
|
||||
<div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
|
||||
<span className="text-slate-900 dark:text-slate-200">
|
||||
{detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
|
||||
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
|
||||
<span className="text-slate-900 dark:text-slate-200">
|
||||
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
|
||||
<span className="text-slate-900 dark:text-slate-200">
|
||||
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
||||
</span>
|
||||
{(detail.output_path || detail.target_dir) && (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
|
||||
<span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
|
||||
{detail.output_path || detail.target_dir}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dateiliste & Download */}
|
||||
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||
<div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
|
||||
<Download size={15} className="text-amber-500" /> Dateien herunterladen
|
||||
</p>
|
||||
{dateien.length > 1 && (
|
||||
<Button
|
||||
variant="amber"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
dateien.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
|
||||
a.download = f.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}, i * 400)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Alle herunterladen ({dateien.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{dateien.map(f => (
|
||||
<a
|
||||
key={f.name}
|
||||
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
||||
download
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Download size={14} className="flex-shrink-0" />
|
||||
<span className="truncate min-w-0">{f.name}</span>
|
||||
{f.size_mb != null && (
|
||||
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
|
||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.error && (
|
||||
<div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<span className="break-words min-w-0">{detail.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { Card, CardHeader, CardTitle } from './ui/Card'
|
||||
|
||||
interface LiveLogEntry {
|
||||
id: string
|
||||
@@ -14,14 +14,13 @@ interface LiveLogEntry {
|
||||
export default function LiveLogSection() {
|
||||
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await api.get('/jobs')
|
||||
const jobs = response.data
|
||||
const current = jobs.find((j: any) => j.status === 'processing')
|
||||
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
|
||||
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Jobs:', error)
|
||||
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
|
||||
|
||||
const getStatusColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error': return 'text-rose-500'
|
||||
case 'warning': return 'text-amber-500'
|
||||
case 'success': return 'text-emerald-500'
|
||||
default: return 'text-blue-500'
|
||||
case 'error': return 'text-rose-500 dark:text-rose-400'
|
||||
case 'warning': return 'text-amber-500 dark:text-amber-400'
|
||||
case 'success': return 'text-emerald-500 dark:text-emerald-400'
|
||||
default: return 'text-sky-500 dark:text-sky-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error': return <AlertCircle size={16} />
|
||||
case 'warning': return <AlertCircle size={16} />
|
||||
case 'success': return <CheckCircle size={16} />
|
||||
default: return <Terminal size={16} />
|
||||
case 'error': return <AlertCircle size={15} />
|
||||
case 'warning': return <AlertCircle size={15} />
|
||||
case 'success': return <CheckCircle size={15} />
|
||||
default: return <Terminal size={15} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
<CardTitle>Live-Log</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{currentJob
|
||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
||||
: 'Kein aktiver Job'}
|
||||
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
|
||||
</div>
|
||||
{currentJob && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
|
||||
currentJob.progress === 100
|
||||
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
||||
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||
}`}>
|
||||
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
|
||||
{currentJob.progress}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${
|
||||
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
|
||||
}`}>
|
||||
<div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
|
||||
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<div key={log.id} className="flex items-start gap-2 text-slate-300">
|
||||
<span className="text-slate-500 whitespace-nowrap">
|
||||
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
|
||||
</span>
|
||||
<span className={`font-bold ${getStatusColor(log.level)}`}>
|
||||
[{log.source}]
|
||||
</span>
|
||||
<span className="flex-1">{log.message}</span>
|
||||
<span className="flex-1 text-slate-200">{log.message}</span>
|
||||
<span className={getStatusColor(log.level)}>
|
||||
{getStatusIcon(log.level)}
|
||||
</span>
|
||||
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
|
||||
{currentJob && (
|
||||
<>
|
||||
<div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<div>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p>
|
||||
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
|
||||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{currentJob.progress}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p>
|
||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{currentJob.device || 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p>
|
||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Loader2, Film } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
|
||||
interface SuchKandidat {
|
||||
title: string
|
||||
year?: number
|
||||
poster?: string
|
||||
overview?: string
|
||||
type: string
|
||||
source: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
devicePath: string
|
||||
vorschlag?: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUebernommen: () => void
|
||||
}
|
||||
|
||||
export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClose, onUebernommen }: Props) {
|
||||
const [suchbegriff, setSuchbegriff] = useState('')
|
||||
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSuchbegriff(vorschlag || '')
|
||||
setKandidaten([])
|
||||
setFehler(null)
|
||||
}
|
||||
}, [isOpen, vorschlag])
|
||||
|
||||
const suchen = async () => {
|
||||
if (!suchbegriff) return
|
||||
setLaeuft(true)
|
||||
setFehler(null)
|
||||
try {
|
||||
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
|
||||
setKandidaten(r.data)
|
||||
if (r.data.length === 0) setFehler('Nichts gefunden — anderen Suchbegriff probieren.')
|
||||
} catch {
|
||||
setFehler('Suche fehlgeschlagen.')
|
||||
} finally {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const waehlen = async (k: SuchKandidat) => {
|
||||
try {
|
||||
await api.post('/metadata/override', {
|
||||
device_path: devicePath,
|
||||
title: k.title,
|
||||
year: k.year,
|
||||
poster: k.poster,
|
||||
overview: k.overview,
|
||||
type: k.type,
|
||||
source: k.source,
|
||||
id: k.id,
|
||||
})
|
||||
onUebernommen()
|
||||
onClose()
|
||||
} catch {
|
||||
setFehler('Übernehmen fehlgeschlagen.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Richtigen Titel wählen"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={suchbegriff}
|
||||
onChange={e => setSuchbegriff(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && suchen()}
|
||||
autoFocus
|
||||
placeholder="Titel eingeben…"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="amber"
|
||||
onClick={suchen}
|
||||
disabled={!suchbegriff || laeuft}
|
||||
>
|
||||
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||
Suchen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
|
||||
|
||||
{kandidaten.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
|
||||
{kandidaten.map((k, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => waehlen(k)}
|
||||
className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
|
||||
>
|
||||
{k.poster ? (
|
||||
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
|
||||
<Film size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2.5">
|
||||
<p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
|
||||
{k.title}
|
||||
</p>
|
||||
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
|
||||
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
/*
|
||||
* Was wird bei „Neu" eigentlich wiederholt?
|
||||
*
|
||||
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
|
||||
* Kompression — auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
|
||||
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
|
||||
*
|
||||
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
|
||||
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
|
||||
* passiert mit dem, was schon auf der Platte liegt?
|
||||
*
|
||||
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
|
||||
* Einführung der Marke), wird nicht geraten — dann stehen beide Wege da, und
|
||||
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
|
||||
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
|
||||
*/
|
||||
|
||||
interface RetryDialogProps {
|
||||
offen: boolean
|
||||
titel: string
|
||||
plan: 'transcode' | 'rip' | 'unklar'
|
||||
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
|
||||
onClose: () => void
|
||||
onStart: (art: 'transcode' | 'rip') => void
|
||||
}
|
||||
|
||||
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
|
||||
if (!rohdaten) return null
|
||||
if (!rohdaten.gb) {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegt zu diesem Job nichts (mehr) — neu rippen ist damit
|
||||
der einzige Weg.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
|
||||
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
|
||||
{' '}Eine Blu-ray bringt roh etwa 25–45 GB mit, eine 4K-UHD 50–100 GB, eine
|
||||
DVD 4–8 GB. Passt die Zahl dazu, war der Rip fertig.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RetryDialog({
|
||||
offen, titel, plan, rohdaten, onClose, onStart,
|
||||
}: RetryDialogProps) {
|
||||
const name = titel || 'Dieser Job'
|
||||
|
||||
return (
|
||||
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
|
||||
{plan === 'transcode' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu komprimieren?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Der Rip war fertig — abgebrochen ist erst die Kompression. Rippy
|
||||
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
|
||||
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
|
||||
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="ghost" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Doch von der Disc neu rippen
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'rip' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu rippen?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
|
||||
auf der Platte liegt, ist ein Bruchstück — komprimieren würde
|
||||
daraus einen Film machen, der mitten drin aufhört. Rippy liest
|
||||
die Disc deshalb von vorn.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
|
||||
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
|
||||
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
|
||||
lässt sich über den Papierkorb daran mitlöschen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'unklar' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}": was soll wiederholt werden?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
|
||||
fertig war oder mitten drin abbrach — er ist älter als der
|
||||
Vermerk dafür. Deshalb wird hier nicht geraten:
|
||||
</p>
|
||||
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
|
||||
<li>
|
||||
<strong>Neu komprimieren</strong> — schnell, braucht die Disc
|
||||
nicht. Richtig, wenn der Rip durchlief.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Neu rippen</strong> — dauert wieder eine Weile und
|
||||
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
|
||||
</li>
|
||||
</ul>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
{!!rohdaten?.gb && (
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input, Select } from './ui/Input'
|
||||
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -9,130 +14,645 @@ interface TargetConfig {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (target: TargetConfig) => void
|
||||
export interface RipOptionen {
|
||||
series?: string
|
||||
season?: number
|
||||
mainFeatureOnly?: boolean
|
||||
titles?: number[]
|
||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
|
||||
// Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der
|
||||
// KOMPRESSION — der Rip bleibt vollständig und verlustfrei.
|
||||
audioSprachen?: string[]
|
||||
untertitelSprachen?: string[]
|
||||
}
|
||||
|
||||
export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) {
|
||||
// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO).
|
||||
interface DiscSprache {
|
||||
lang: string // ISO-639-2, z. B. "deu"
|
||||
sprache: string // Klartext, z. B. "German"
|
||||
spuren: number
|
||||
}
|
||||
|
||||
// Ein Ablageziel aus GET /storage-targets.
|
||||
interface StorageZiel {
|
||||
name: string
|
||||
path: string
|
||||
is_mount: boolean
|
||||
free_gb: number | null
|
||||
}
|
||||
|
||||
interface WorkerWahl {
|
||||
name: string
|
||||
node: string | null
|
||||
online?: boolean
|
||||
encoders: string[]
|
||||
// extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die
|
||||
// Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet
|
||||
// beides selbst (worker/caps.py).
|
||||
info?: { extern?: string, pfad_map?: string }
|
||||
}
|
||||
|
||||
const ENCODER_KURZ: Record<string, string> = {
|
||||
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1',
|
||||
'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||
}
|
||||
|
||||
interface TitelInfo {
|
||||
nr: number
|
||||
dauer_s: number
|
||||
groesse_bytes: number
|
||||
kapitel: number
|
||||
}
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
initialType?: TargetConfig['type']
|
||||
discTitle?: string
|
||||
deviceId?: string
|
||||
onClose: () => void
|
||||
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||
}
|
||||
|
||||
function dauerText(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`
|
||||
}
|
||||
|
||||
interface BrowseDir {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||
const [serienName, setSerienName] = useState('')
|
||||
const [staffel, setStaffel] = useState(1)
|
||||
const [nurHauptfilm, setNurHauptfilm] = useState(false)
|
||||
const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||
const [scanFehler, setScanFehler] = useState('')
|
||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||
/*
|
||||
* Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material
|
||||
* in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau
|
||||
* willst du haben?").
|
||||
*
|
||||
* Die Auskunft kommt aus demselben Titel-Scan, der schon für die
|
||||
* Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie
|
||||
* wurde bisher nur weggeworfen.
|
||||
*
|
||||
* Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt
|
||||
* das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur.
|
||||
*/
|
||||
const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null)
|
||||
const [audioWahl, setAudioWahl] = useState<Set<string>>(new Set())
|
||||
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
|
||||
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
|
||||
// soweit die Disc sie überhaupt hat.
|
||||
const [standardAudio, setStandardAudio] = useState<string[]>([])
|
||||
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
|
||||
|
||||
const scanStarten = async () => {
|
||||
if (!deviceId) return
|
||||
setScanStatus('running')
|
||||
setScanFehler('')
|
||||
try {
|
||||
await api.post(`/devices/${deviceId}/scan-tracks`)
|
||||
} catch (e: any) {
|
||||
setScanStatus('error')
|
||||
setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (scanStatus !== 'running' || !deviceId) return
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const r = await api.get(`/devices/${deviceId}/tracks`)
|
||||
if (r.data.status === 'done') {
|
||||
const tracks: TitelInfo[] = r.data.tracks || []
|
||||
setTitelListe(tracks)
|
||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||
const spr = r.data.sprachen || null
|
||||
setDiscSprachen(spr)
|
||||
// Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat.
|
||||
// Sonst stünde da eine Auswahl, die nichts bewirkt.
|
||||
if (spr) {
|
||||
const vorhanden = (liste: DiscSprache[], wunsch: string[]) =>
|
||||
new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang))
|
||||
setAudioWahl(vorhanden(spr.audio || [], standardAudio))
|
||||
setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel))
|
||||
}
|
||||
setScanStatus('done')
|
||||
} else if (r.data.status === 'error') {
|
||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||
setScanStatus('error')
|
||||
}
|
||||
} catch { /* API kurz weg — weiter pollen */ }
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [scanStatus, deviceId])
|
||||
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
||||
])
|
||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||
const [customPath, setCustomPath] = useState('')
|
||||
const { theme } = useDarkMode()
|
||||
// Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier
|
||||
// der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht
|
||||
// wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein
|
||||
// beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl
|
||||
// darüber, und der Browser überschrieb sie stillschweigend, sobald man auf
|
||||
// „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist
|
||||
// die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen
|
||||
// und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt.
|
||||
const [browserOffen, setBrowserOffen] = useState(false)
|
||||
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
||||
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
||||
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
||||
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
||||
const [arbeitsDir, setArbeitsDir] = useState('')
|
||||
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
|
||||
|
||||
const defaultTargets: TargetConfig[] = [
|
||||
{ id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true },
|
||||
{ id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true },
|
||||
]
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setSelectedType(initialType || 'movies')
|
||||
setCustomPath('')
|
||||
setSerienName(discTitle || '')
|
||||
setStaffel(1)
|
||||
setScanStatus('idle')
|
||||
setScanFehler('')
|
||||
setTitelListe([])
|
||||
setGewaehlt(new Set())
|
||||
setEncoderNode('')
|
||||
setArbeitsDir('')
|
||||
setBrowserOffen(false)
|
||||
setDiscSprachen(null)
|
||||
setAudioWahl(new Set())
|
||||
setUntertitelWahl(new Set())
|
||||
api.get('/storage-targets')
|
||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||
.catch(() => setArbeitsZiele([]))
|
||||
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||
api.get('/capabilities').then(r => {
|
||||
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||
}).catch(() => setWorkers([]))
|
||||
api.get('/settings').then(r => {
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
setStandardArbeitsDir((s.workDir || '').trim())
|
||||
const codes = (wert: any) => String(wert || '')
|
||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||
setStandardAudio(codes(s.audioSprachen))
|
||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||
const basis = s.outputDir || '/app/media'
|
||||
setTargets([
|
||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
|
||||
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
||||
])
|
||||
}).catch(() => {})
|
||||
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
|
||||
// Normalfall gar nicht.
|
||||
}, [isOpen])
|
||||
|
||||
const [targets, setTargets] = useState(defaultTargets)
|
||||
const laden = async (pfad: string) => {
|
||||
try {
|
||||
const r = await api.get('/browse', { params: { path: pfad } })
|
||||
setBrowsePath(r.data.path)
|
||||
setBrowseParent(r.data.parent)
|
||||
setBrowseDirs(r.data.dirs)
|
||||
} catch {
|
||||
setBrowseDirs([])
|
||||
}
|
||||
}
|
||||
|
||||
// Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner.
|
||||
const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || ''
|
||||
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
||||
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
||||
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
||||
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||
const gewaehlterWorker = workers.find(w => w.node === encoderNode)
|
||||
/*
|
||||
* Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte
|
||||
* Worker erreicht. Aus /app/media/movies wird /app/media/<freigabe>/movies.
|
||||
*
|
||||
* Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf
|
||||
* liegt — sonst stünde ein Knopf da, der nichts tut.
|
||||
*/
|
||||
const zielAufFreigabe = (() => {
|
||||
const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0]
|
||||
if (!freigabe || !zielPfad) return ''
|
||||
const unterordner = zielPfad.split('/').filter(Boolean).pop() || ''
|
||||
const neu = `/app/media/${freigabe}/${unterordner}`
|
||||
return neu === zielPfad ? '' : neu
|
||||
})()
|
||||
const pfadWarnung = selectedType === 'music'
|
||||
? null
|
||||
: encoderNode
|
||||
? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv)
|
||||
// „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch
|
||||
// einen, der die Pfade nicht erreicht.
|
||||
: automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv)
|
||||
|
||||
const handleSave = () => {
|
||||
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||
if (selectedType === 'series' && serienName.trim()) {
|
||||
optionen.series = serienName.trim()
|
||||
optionen.season = Math.max(1, staffel || 1)
|
||||
}
|
||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||
}
|
||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
||||
if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl]
|
||||
if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl]
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (target) {
|
||||
onSave(target)
|
||||
if (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
} else if (target) {
|
||||
onSave(target, optionen)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
||||
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
{/* Header */}
|
||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
|
||||
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Wohin sollen die gerippten Dateien gespeichert werden?
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Rip-Ziel auswählen"
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Type Selection */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
Dateityp
|
||||
</label>
|
||||
{/* Schnellwahl */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{['movies', 'series', 'music'].map((type) => (
|
||||
{targets.map((t) => {
|
||||
const isSelected = selectedType === t.type && !customPath
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type as TargetConfig['type'])}
|
||||
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
|
||||
selectedType === type
|
||||
? theme === 'dark'
|
||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
||||
: theme === 'dark'
|
||||
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
key={t.type}
|
||||
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
||||
className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
|
||||
isSelected
|
||||
? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
|
||||
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">
|
||||
{type === 'movies' && '🎥'}
|
||||
{type === 'series' && '📺'}
|
||||
{type === 'music' && '🎵'}
|
||||
</span>
|
||||
<span className="text-sm font-medium capitalize">{type}</span>
|
||||
{t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
{t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
{t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Path Display */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
Zielverzeichnis
|
||||
</label>
|
||||
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<span className="text-xl">📁</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customPath || targets.find(t => t.type === selectedType)?.path || ''}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}
|
||||
placeholder="/path/to/target"
|
||||
{/* Serien-Flow */}
|
||||
{selectedType === 'series' && (
|
||||
<div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||
<div className="grid grid-cols-[1fr_90px] gap-3">
|
||||
<Input
|
||||
label="Serienname"
|
||||
value={serienName}
|
||||
onChange={e => setSerienName(e.target.value)}
|
||||
placeholder="z. B. Neon Genesis Evangelion"
|
||||
/>
|
||||
<Input
|
||||
label="Staffel"
|
||||
type="number"
|
||||
min={1}
|
||||
value={staffel}
|
||||
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (target) {
|
||||
setCustomPath(target.path)
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Standard
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
||||
{customPath
|
||||
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
|
||||
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
|
||||
}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hauptfilm-Wahl */}
|
||||
{selectedType === 'movies' && scanStatus !== 'done' && (
|
||||
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nurHauptfilm}
|
||||
onChange={e => setNurHauptfilm(e.target.checked)}
|
||||
className="w-4 h-4 accent-amber-500 rounded"
|
||||
/>
|
||||
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026).
|
||||
Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß
|
||||
und lag bisher immer auf der Container-Platte — am 25.07. lief sie
|
||||
damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur
|
||||
Disc, nicht in eine globale Einstellung. Leer = der Wert aus
|
||||
Einstellungen → Verarbeitung; genau der greift auch bei
|
||||
Vollautomatik-Rips, weil dort niemand gefragt wird.
|
||||
Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
|
||||
*/}
|
||||
{selectedType !== 'music' && (
|
||||
<div>
|
||||
<Select
|
||||
label="Arbeitsverzeichnis für die Rohdaten"
|
||||
value={arbeitsDir}
|
||||
onChange={e => setArbeitsDir(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Standard aus den Einstellungen
|
||||
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
||||
</option>
|
||||
{arbeitsZiele.map(z => (
|
||||
<option key={z.path} value={z.path}>
|
||||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={13} />
|
||||
Bei 4K-UHD bis zu 100 GB — nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
|
||||
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||
{selectedType !== 'music' && workers.length >= 2 && (
|
||||
<div>
|
||||
<Select
|
||||
label="Encoder / Worker für die Kompression"
|
||||
value={encoderNode}
|
||||
onChange={e => setEncoderNode(e.target.value)}
|
||||
>
|
||||
<option value="">Automatisch (erster freier Worker)</option>
|
||||
{workers.map(w => (
|
||||
<option key={w.node!} value={w.node!}>
|
||||
{w.name} — {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe
|
||||
Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er
|
||||
die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */}
|
||||
{pfadWarnung && (
|
||||
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||||
<strong>⚠️ Dieser Encoder kann so nicht arbeiten.</strong>
|
||||
<p className="mt-1">{pfadWarnung}</p>
|
||||
{/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR
|
||||
DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die
|
||||
globale Ablage anzufassen. „Geh in die Einstellungen" mitten im
|
||||
Dialog ist lästig, und wer den Rip jetzt starten will, will
|
||||
jetzt eine Lösung. */}
|
||||
{zielAufFreigabe && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => { setCustomPath(zielAufFreigabe); setBrowserOffen(false) }}
|
||||
>
|
||||
Ziel für diesen Rip auf die Freigabe legen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||
{deviceId && selectedType !== 'music' && (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
|
||||
{scanStatus === 'idle' && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Disc scannen und einzelne Titel an-/abwählen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{scanStatus === 'running' ? (
|
||||
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
|
||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
|
||||
Disc wird gelesen (20–120 s)…
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={scanStarten}>
|
||||
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
{scanStatus === 'error' && (
|
||||
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
|
||||
)}
|
||||
|
||||
{scanStatus === 'done' && titelListe.length > 0 && (
|
||||
<>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{titelListe.map(t => (
|
||||
<label
|
||||
key={t.nr}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gewaehlt.has(t.nr)}
|
||||
onChange={e => {
|
||||
const neu = new Set(gewaehlt)
|
||||
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
|
||||
setGewaehlt(neu)
|
||||
}}
|
||||
className="w-4 h-4 accent-amber-500 rounded"
|
||||
/>
|
||||
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
|
||||
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
|
||||
{t.kapitel > 0 && (
|
||||
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs font-mono text-slate-400">
|
||||
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
|
||||
{gewaehlt.size} von {titelListe.length} Titeln gewählt
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides
|
||||
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
|
||||
{scanStatus === 'done' && discSprachen
|
||||
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||
Sprachen auf dieser Disc
|
||||
</span>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
|
||||
{discSprachen.untertitel.length > 0
|
||||
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
|
||||
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{(['audio', 'untertitel'] as const).map(art => {
|
||||
const liste = discSprachen[art]
|
||||
if (liste.length === 0) return null
|
||||
const wahl = art === 'audio' ? audioWahl : untertitelWahl
|
||||
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
|
||||
return (
|
||||
<div key={art}>
|
||||
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
|
||||
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{liste.map(s => {
|
||||
const an = wahl.has(s.lang)
|
||||
return (
|
||||
<button
|
||||
key={s.lang}
|
||||
onClick={() => {
|
||||
const neu = new Set(wahl)
|
||||
if (an) neu.delete(s.lang); else neu.add(s.lang)
|
||||
setzen(neu)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
an
|
||||
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
|
||||
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{sprachName(s.lang, s.sprache)}
|
||||
<span className="opacity-70"> ({s.lang})</span>
|
||||
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
|
||||
die Disc würde unvollständig gerippt. */}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
|
||||
<>
|
||||
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> — die
|
||||
Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit
|
||||
„Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch
|
||||
einmal einzulegen.
|
||||
</>
|
||||
) : (
|
||||
<>Ohne Auswahl behält Rippy alle Sprachen — wie bisher.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const auf = !browserOffen
|
||||
setBrowserOffen(auf)
|
||||
if (auf && browseDirs.length === 0) laden('/app/media')
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
>
|
||||
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
Anderen Zielordner wählen (nur für diesen Rip)
|
||||
</button>
|
||||
|
||||
{browserOffen && (
|
||||
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||
<button
|
||||
onClick={() => browseParent && laden(browseParent)}
|
||||
disabled={!browseParent}
|
||||
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||
title="Ordner hoch"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||
<Button
|
||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||
>
|
||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-36 overflow-y-auto p-1">
|
||||
{browseDirs.map(d => (
|
||||
<button
|
||||
key={d.path}
|
||||
onClick={() => laden(d.path)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Folder size={14} className="text-slate-400" />
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
{browseDirs.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gewähltes Ziel Display */}
|
||||
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||
{zielPfad}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="amber" onClick={handleSave}>
|
||||
Rippen starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input, Select } from './ui/Input'
|
||||
|
||||
interface Mount {
|
||||
name: string
|
||||
type: string
|
||||
source: string
|
||||
mounted: boolean
|
||||
reachable?: boolean
|
||||
has_credentials: boolean
|
||||
}
|
||||
|
||||
interface Target {
|
||||
name: string
|
||||
path: string
|
||||
is_mount: boolean
|
||||
free_gb: number | null
|
||||
}
|
||||
|
||||
export default function StorageMounts() {
|
||||
const [mounts, setMounts] = useState<Mount[]>([])
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
|
||||
const [source, setSource] = useState('')
|
||||
const [host, setHost] = useState('')
|
||||
const [shares, setShares] = useState<string[]>([])
|
||||
const [selectedShare, setSelectedShare] = useState('')
|
||||
const [sharesBusy, setSharesBusy] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
const [browsePath, setBrowsePath] = useState('/app/media')
|
||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||
const [browserOffen, setBrowserOffen] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const browsen = async (pfad: string) => {
|
||||
try {
|
||||
const r = await api.get('/browse', { params: { path: pfad } })
|
||||
setBrowsePath(r.data.path)
|
||||
setBrowseParent(r.data.parent)
|
||||
setBrowseDirs(r.data.dirs)
|
||||
setBrowseFiles(r.data.files || [])
|
||||
} catch {
|
||||
setBrowseDirs([])
|
||||
setBrowseFiles([])
|
||||
}
|
||||
}
|
||||
|
||||
const ordnerAnlegen = async () => {
|
||||
if (!neuerOrdner) return
|
||||
try {
|
||||
await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner })
|
||||
setNeuerOrdner('')
|
||||
browsen(browsePath)
|
||||
laden()
|
||||
} catch (e: any) {
|
||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const laden = async (manuell = false) => {
|
||||
try {
|
||||
const [m, t] = await Promise.allSettled([
|
||||
api.get('/storage-mounts'),
|
||||
api.get('/storage-targets'),
|
||||
])
|
||||
if (m.status === 'fulfilled') setMounts(m.value.data || [])
|
||||
if (t.status === 'fulfilled') setTargets(t.value.data || [])
|
||||
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||
} catch {
|
||||
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { laden(); browsen('/app/media') }, [])
|
||||
|
||||
const freigabenAuflisten = async () => {
|
||||
if (!host) return
|
||||
setSharesBusy(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
const r = await api.get('/storage-mounts/shares', {
|
||||
params: { host, ...(username ? { username, password } : {}) },
|
||||
})
|
||||
setShares(r.data.shares)
|
||||
if (r.data.shares.length === 0) {
|
||||
setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?')
|
||||
} else {
|
||||
setSelectedShare(r.data.shares[0])
|
||||
}
|
||||
} catch (e: any) {
|
||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`)
|
||||
setShares([])
|
||||
} finally {
|
||||
setSharesBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hinzufuegen = async () => {
|
||||
setBusy(true)
|
||||
setFeedback(null)
|
||||
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
||||
const zielName = name
|
||||
try {
|
||||
const r = await api.post('/storage-mounts', {
|
||||
name, type: typ, source: quelle,
|
||||
...(username ? { username, password } : {}),
|
||||
})
|
||||
if (r.data.writable) {
|
||||
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
|
||||
toast('success', `„${zielName}" eingehängt — Schreibtest OK`)
|
||||
} 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('')
|
||||
laden()
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
|
||||
setFeedback(`✗ ${detail}`)
|
||||
toast('error', `„${zielName}": ${detail}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const entfernen = async (mountName: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.delete(`/storage-mounts/${mountName}`)
|
||||
toast('success', `„${mountName}" ausgehängt und entfernt`)
|
||||
laden()
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
|
||||
setFeedback(`✗ ${detail}`)
|
||||
toast('error', detail)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reparieren = async (mountName: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await api.post(`/storage-mounts/${mountName}/repair`)
|
||||
toast(r.data.writable ? 'success' : 'error',
|
||||
r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`)
|
||||
laden()
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Vorhandene Ziele */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive size={20} className="text-amber-500" />
|
||||
<span>Verfügbare Ziele</span>
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targets.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{targets.map(t => (
|
||||
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
|
||||
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
|
||||
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{mounts.some(m => m.name === t.name) && (
|
||||
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
|
||||
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
|
||||
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
|
||||
{mounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive size={20} className="text-amber-500" />
|
||||
<span>Netzwerk-Mounts</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2.5">
|
||||
{mounts.map(m => {
|
||||
const aktiv = m.mounted && m.reachable !== false
|
||||
const tot = m.mounted && m.reachable === false
|
||||
return (
|
||||
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{aktiv
|
||||
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
||||
: tot
|
||||
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
||||
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
||||
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
||||
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{!aktiv && (
|
||||
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
||||
<Wrench size={14} /> Reparieren
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Ordner-Verwaltung */}
|
||||
<Card>
|
||||
<button
|
||||
onClick={() => setBrowserOffen(!browserOffen)}
|
||||
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Ordner-Verwaltung (optional)
|
||||
</h3>
|
||||
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
|
||||
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder „doku".
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
|
||||
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{browserOffen && (
|
||||
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
|
||||
<button
|
||||
onClick={() => browseParent && browsen(browseParent)}
|
||||
disabled={!browseParent}
|
||||
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
|
||||
title="Ordner hoch"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||
<input
|
||||
value={neuerOrdner}
|
||||
onChange={e => setNeuerOrdner(e.target.value)}
|
||||
placeholder="neuer-ordner"
|
||||
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={ordnerAnlegen}
|
||||
disabled={!neuerOrdner}
|
||||
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
title="Ordner anlegen"
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto p-2">
|
||||
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
|
||||
) : (
|
||||
<>
|
||||
{browseDirs.map(d => (
|
||||
<button
|
||||
key={d.path}
|
||||
onClick={() => browsen(d.path)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
{browseFiles.map(f => (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
<File size={14} className="flex-shrink-0" />
|
||||
<span className="truncate">{f.name}</span>
|
||||
{f.size_mb != null && (
|
||||
<span className="ml-auto text-xs flex-shrink-0 font-mono">
|
||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Neues Netzwerk-Ziel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus size={20} className="text-amber-500" />
|
||||
<span>Netzwerk-Ziel hinzufügen</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Name (klein, ohne Leerzeichen)"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value.toLowerCase())}
|
||||
placeholder="nas-filme"
|
||||
/>
|
||||
<Select
|
||||
label="Typ"
|
||||
value={typ}
|
||||
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
|
||||
>
|
||||
<option value="nfs">NFS (NAS/Linux)</option>
|
||||
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
||||
</Select>
|
||||
|
||||
{typ === 'nfs' ? (
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Quelle (host:/export/pfad)"
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value)}
|
||||
placeholder="192.168.1.10:/volume1/filme"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Rechner (dein PC oder NAS — Name oder IP)"
|
||||
value={host}
|
||||
onChange={e => setHost(e.target.value)}
|
||||
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Benutzer"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Freigabe-Benutzer"
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben versucht Rippy einen Gast-Zugriff.
|
||||
</p>
|
||||
<div className="md:col-span-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={freigabenAuflisten}
|
||||
disabled={!host || sharesBusy}
|
||||
>
|
||||
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
||||
</Button>
|
||||
</div>
|
||||
{shares.length > 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<Select
|
||||
label="Freigabe wählen"
|
||||
value={selectedShare}
|
||||
onChange={e => setSelectedShare(e.target.value)}
|
||||
>
|
||||
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="amber"
|
||||
onClick={hinzufuegen}
|
||||
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
||||
>
|
||||
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||
</Button>
|
||||
|
||||
{feedback && (
|
||||
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||
{feedback}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { simdWarnung } from '../lib/encoder'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
|
||||
interface WorkerInfo {
|
||||
name: string
|
||||
encoders: string[]
|
||||
last_seen?: string
|
||||
online?: boolean
|
||||
info?: {
|
||||
hostname?: string
|
||||
ip?: string
|
||||
makemkv?: string
|
||||
handbrake?: string
|
||||
cpu_modell?: string
|
||||
cpu_kerne?: string
|
||||
cpu_simd?: string
|
||||
handbrake_encoder?: string
|
||||
rippy_version?: string
|
||||
}
|
||||
}
|
||||
|
||||
const ENCODER_LABELS: Record<string, string> = {
|
||||
'cpu-x264': 'CPU · H.264',
|
||||
'cpu-x265': 'CPU · H.265',
|
||||
'cpu-av1': 'CPU · AV1 (SVT)',
|
||||
'vaapi': 'GPU · VAAPI (AMD/Intel)',
|
||||
'nvenc': 'GPU · NVENC (NVIDIA)',
|
||||
}
|
||||
|
||||
|
||||
function relativeZeit(iso?: string): string {
|
||||
if (!iso) return 'nie'
|
||||
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (sekunden < 90) return 'gerade eben'
|
||||
if (sekunden < 3600) return `vor ${Math.floor(sekunden / 60)} min`
|
||||
return `vor ${Math.floor(sekunden / 3600)} h`
|
||||
}
|
||||
|
||||
export default function WorkerVerwaltung() {
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [serverVersion, setServerVersion] = useState<string>('dev')
|
||||
const [kopiert, setKopiert] = useState(false)
|
||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
|
||||
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
|
||||
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
|
||||
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||
const [rippyHost, setRippyHost] = useState('')
|
||||
const { toast } = useToast()
|
||||
|
||||
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
|
||||
|
||||
const siehtOeffentlichAus =
|
||||
rippyHost !== '' &&
|
||||
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
||||
!rippyHost.endsWith('.local') &&
|
||||
rippyHost !== 'localhost' &&
|
||||
rippyHost.includes('.')
|
||||
|
||||
const laden = (manuell = false) => {
|
||||
api.get('/capabilities')
|
||||
.then(r => {
|
||||
setWorkers(r.data.workers || [])
|
||||
if (r.data.server_version) setServerVersion(r.data.server_version)
|
||||
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||
})
|
||||
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden()
|
||||
const interval = setInterval(laden, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const installBefehl = variante === 'linux'
|
||||
? [
|
||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
|
||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
].join('\n')
|
||||
: [
|
||||
// Standard-Ziel ist "C:\Program Files\Rippy Worker" - dorthin darf nur
|
||||
// ein Administrator schreiben, deshalb der Hinweis direkt im Befehl.
|
||||
// Die .exe fragt selbst per UAC; hier muss die Konsole erhöht sein.
|
||||
`# PowerShell als Administrator starten (Standard-Ziel: Programme)`,
|
||||
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||
`# Ohne Adminrechte: -InstallDir "$env:LOCALAPPDATA\\Rippy Worker" anhaengen`,
|
||||
].join('\n')
|
||||
|
||||
const kopieren = () => {
|
||||
navigator.clipboard.writeText(installBefehl).then(() => {
|
||||
setKopiert(true)
|
||||
setTimeout(() => setKopiert(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
// Fertige Installer-.exe (Rippy-Icon, kein Konsolenfenster) direkt laden.
|
||||
// Generisch — die GUI fragt die Rippy-Adresse selbst ab.
|
||||
const exeHerunterladen = () => {
|
||||
const a = document.createElement('a')
|
||||
a.href = '/api/worker-setup/windows-exe'
|
||||
a.download = 'RippyWorkerSetup.exe'
|
||||
document.body.appendChild(a); a.click(); a.remove()
|
||||
toast('success', 'RippyWorkerSetup.exe wird heruntergeladen — doppelklicken zum Installieren')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Verbundene Worker */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server size={20} className="text-amber-500" />
|
||||
<span>Verbundene Worker</span>
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{workers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{workers.map(w => (
|
||||
<div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
|
||||
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
||||
/>
|
||||
<Server size={18} className="text-slate-400 dark:text-slate-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||
</p>
|
||||
{w.info?.rippy_version && (
|
||||
<p className={`text-xs ${w.info.rippy_version !== serverVersion ? 'text-rose-500 font-semibold' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
Version {w.info.rippy_version} {w.info.rippy_version !== serverVersion ? `(Veraltet! Server hat ${serverVersion})` : ''}
|
||||
</p>
|
||||
)}
|
||||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||||
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{simdWarnung(w.info?.cpu_simd) && (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400 max-w-xl">
|
||||
{simdWarnung(w.info?.cpu_simd)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{w.encoders.map(e => (
|
||||
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
|
||||
e.startsWith('cpu')
|
||||
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||
}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!w.online && (
|
||||
<button
|
||||
onClick={() => {
|
||||
api.delete(`/workers/${encodeURIComponent(w.name)}`)
|
||||
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
||||
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
||||
}}
|
||||
title="Verwaisten Eintrag entfernen"
|
||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Neue Maschine anbinden */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||
value={rippyHost}
|
||||
onChange={e => setRippyHost(e.target.value.trim())}
|
||||
placeholder="z. B. 192.168.1.10"
|
||||
/>
|
||||
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
|
||||
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
|
||||
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
|
||||
an (Ports 6379/5432) — das klappt nur mit der echten LAN-IP im Heimnetz.
|
||||
</p>
|
||||
{siehtOeffentlichAus && (
|
||||
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||
<strong> LAN-IP</strong> der Rippy-Maschine eintragen — die beginnt im Heimnetz
|
||||
fast immer mit 192.168., 10. oder 172.16.–172.31.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
||||
<Button
|
||||
key={wert}
|
||||
variant={variante === wert ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setVariante(wert)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
|
||||
{variante === 'windows' && (
|
||||
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
|
||||
im Fenster Adresse + Name eintragen, „Installieren". Kein Konsolenfenster.
|
||||
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
|
||||
</p>
|
||||
<Button variant="amber" onClick={exeHerunterladen}>
|
||||
<Download size={16} /> Installer herunterladen (.exe)
|
||||
</Button>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
|
||||
→ „Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
|
||||
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{variante === 'linux'
|
||||
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||
: 'Alternativ (für Profis) per PowerShell-Befehl:'}
|
||||
</p>
|
||||
|
||||
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
|
||||
{installBefehl}
|
||||
<button
|
||||
onClick={kopieren}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier, wo das Laufwerk hängt.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Disc } from 'lucide-react'
|
||||
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
|
||||
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
|
||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
label: status,
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface TypeBadgeProps {
|
||||
type: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
label: type.toUpperCase(),
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
|
||||
<Disc size={13} />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
|
||||
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
|
||||
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
|
||||
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
|
||||
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
|
||||
md: 'px-4 py-2 text-sm gap-2',
|
||||
lg: 'px-5 py-2.5 text-base gap-2.5',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
glow?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||
glow
|
||||
? 'glass-panel-amber'
|
||||
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||
} text-slate-900 dark:text-slate-100 ${
|
||||
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Top subtle highlight line for 3D glass depth */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<div className="space-y-1.5 w-full">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
|
||||
error ? 'border-rose-500 ring-rose-500/20' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-rose-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Select({ label, children, className = '', ...props }: SelectProps) {
|
||||
return (
|
||||
<div className="space-y-1.5 w-full">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
{(label || description) && (
|
||||
<div className="space-y-0.5">
|
||||
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
|
||||
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
|
||||
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const maxWidening = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
}
|
||||
|
||||
// WICHTIG (Befund 25.07.2026): Der Dialog wird per Portal direkt an
|
||||
// document.body gehängt statt dort zu bleiben, wo er im Baum steht.
|
||||
// Grund: `.glass-panel` in index.css setzt `backdrop-filter: blur(16px)`,
|
||||
// und ein Element mit backdrop-filter wird zum Bezugsrahmen für
|
||||
// `position: fixed` seiner Nachfahren. Ein Modal INNERHALB einer Card war
|
||||
// damit nicht mehr am Fenster ausgerichtet, sondern an der Card — es klebte
|
||||
// im Panel und wurde am Rand abgeschnitten (gemeldet für „Rippen starten"
|
||||
// im Laufwerke-Tab). Das Portal löst das für ALLE Dialoge auf einmal.
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||
{/* Glass Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Dialog */}
|
||||
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
interface RadialProgressProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||
|
||||
const isComplete = progress === 100
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-slate-800 dark:text-slate-800/80 fill-none"
|
||||
/>
|
||||
{/* Progress track */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="url(#progressGradient)"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="fill-none transition-all duration-500 ease-out"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
|
||||
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
|
||||
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
|
||||
{progress}%
|
||||
</span>
|
||||
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,25 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
import { createContext, useContext, useEffect, ReactNode } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
theme: 'dark'
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
setTheme('dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
setTheme('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', 'dark')
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
if (newTheme === 'dark') {
|
||||
// 100% Dark Mode locked per Commander directive
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+75
-45
@@ -2,52 +2,82 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-color: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #64748b;
|
||||
--accent-color: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--border-color: #e2e8f0;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #e2e8f0;
|
||||
--success-color: #22c55e;
|
||||
--error-color: #ef4444;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-color: #818cf8;
|
||||
--accent-hover: #6366f1;
|
||||
--border-color: #334155;
|
||||
--card-bg: #1e293b;
|
||||
--input-bg: #0f172a;
|
||||
--input-border: #334155;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
@apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
/* Toast Einflug */
|
||||
@keyframes toast-rein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-rein {
|
||||
animation: toast-rein 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Subtile Disc-Rotation für Cinematic Spotlight */
|
||||
@keyframes disc-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-disc-spin {
|
||||
animation: disc-spin 12s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom Glass & Shimmer Effects */
|
||||
.glass-panel {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-panel-amber {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Metallic Disc Texture Gradient */
|
||||
.disc-texture {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
#1e293b 0deg,
|
||||
#475569 45deg,
|
||||
#1e293b 90deg,
|
||||
#64748b 135deg,
|
||||
#1e293b 180deg,
|
||||
#475569 225deg,
|
||||
#1e293b 270deg,
|
||||
#64748b 315deg,
|
||||
#1e293b 360deg
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,9 @@ import axios from 'axios'
|
||||
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
||||
export const api = axios.create({ baseURL: '/api' })
|
||||
// Zeitgrenze, damit ein hängender Aufruf nicht ewig offen bleibt und sich die
|
||||
// Abfragen des 4-Sekunden-Takts stapeln (Befund 26.07.2026: axios wartet ohne
|
||||
// `timeout` unbegrenzt). 20 s sind reichlich für jeden Endpunkt — die
|
||||
// langsamsten sind der Update-Check und der Titel-Scan-Start, alles andere
|
||||
// antwortet in Millisekunden.
|
||||
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Centralized Design System & Style Tokens for Rippy (Cinematic Cinema OS)
|
||||
|
||||
export const STATUS_STYLES = {
|
||||
pending: {
|
||||
badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||
label: 'Wartend',
|
||||
},
|
||||
processing: {
|
||||
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30 animate-pulse',
|
||||
label: 'Rippen',
|
||||
},
|
||||
transcoding: {
|
||||
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
label: 'Komprimieren',
|
||||
},
|
||||
canceling: {
|
||||
badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||
label: 'Wird abgebrochen…',
|
||||
},
|
||||
completed: {
|
||||
badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
|
||||
label: 'Fertig',
|
||||
},
|
||||
failed: {
|
||||
badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30',
|
||||
label: 'Fehler',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const DISC_TYPE_STYLES = {
|
||||
cd: {
|
||||
badge: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-indigo-500/30',
|
||||
aura: 'from-indigo-500/20 via-indigo-900/10 to-transparent',
|
||||
glow: 'shadow-[0_0_20px_-5px_rgba(99,102,241,0.2)]',
|
||||
label: 'CD',
|
||||
},
|
||||
dvd: {
|
||||
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
aura: 'from-purple-500/20 via-purple-900/10 to-transparent',
|
||||
glow: 'shadow-[0_0_20px_-5px_rgba(168,85,247,0.2)]',
|
||||
label: 'DVD',
|
||||
},
|
||||
bluray: {
|
||||
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
||||
aura: 'from-sky-500/20 via-sky-900/10 to-transparent',
|
||||
glow: 'shadow-[0_0_20px_-5px_rgba(14,165,233,0.2)]',
|
||||
label: 'BLU-RAY',
|
||||
},
|
||||
uhd: {
|
||||
badge: 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40 font-semibold shadow-sm shadow-amber-500/20',
|
||||
aura: 'from-amber-500/25 via-amber-900/15 to-transparent',
|
||||
glow: 'shadow-[0_0_25px_-5px_rgba(245,158,11,0.25)]',
|
||||
label: '4K UHD',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const LOG_LEVEL_STYLES = {
|
||||
INFO: {
|
||||
color: 'text-sky-600 dark:text-sky-400',
|
||||
bg: 'bg-sky-500/10',
|
||||
border: 'border-sky-500/20',
|
||||
},
|
||||
WARNING: {
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500/20',
|
||||
},
|
||||
ERROR: {
|
||||
color: 'text-rose-600 dark:text-rose-400',
|
||||
bg: 'bg-rose-500/10',
|
||||
border: 'border-rose-500/20',
|
||||
},
|
||||
DEBUG: {
|
||||
color: 'text-slate-500 dark:text-slate-400',
|
||||
bg: 'bg-slate-500/10',
|
||||
border: 'border-slate-500/20',
|
||||
},
|
||||
} as const
|
||||
|
||||
// Encoder-Backends, wie worker/caps.py sie meldet. Hardware wird seit
|
||||
// 25.07.2026 nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) — vorher landete
|
||||
// eine AMD-Karte unter dem Intel-Namen „vaapi", und AV1 in Hardware war gar
|
||||
// nicht sichtbar, obwohl es die beste Kombination aus Tempo und Größe ist.
|
||||
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
||||
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
||||
'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
|
||||
'nvenc': { label: 'NVENC ⚡ (NVIDIA)', highlight: true },
|
||||
'nvenc-av1': { label: 'AV1 · NVENC ⚡', highlight: true },
|
||||
'qsv': { label: 'QuickSync ⚡ (Intel)', highlight: true },
|
||||
'qsv-av1': { label: 'AV1 · QuickSync ⚡', highlight: true },
|
||||
'vce': { label: 'VCE ⚡ (AMD)', highlight: true },
|
||||
'vce-av1': { label: 'AV1 · VCE ⚡', highlight: true },
|
||||
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||
'vaapi-av1': { label: 'AV1 · VAAPI ⚡', highlight: true },
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Encoder-Fähigkeiten eines Workers in Klartext übersetzen.
|
||||
//
|
||||
// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell
|
||||
// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2.
|
||||
// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene
|
||||
// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die
|
||||
// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet.
|
||||
|
||||
// Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||
export const SIMD_SCHNELL = ['avx512f', 'avx2']
|
||||
|
||||
/**
|
||||
* Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist.
|
||||
* Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist —
|
||||
* lieber nichts sagen als etwas Falsches behaupten.
|
||||
*/
|
||||
export function simdWarnung(simd?: string): string | null {
|
||||
if (!simd || simd === 'unbekannt') return null
|
||||
if (SIMD_SCHNELL.includes(simd)) return null
|
||||
return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier `
|
||||
+ 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den '
|
||||
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
|
||||
+ 'Hardware-Encoder wählen.'
|
||||
}
|
||||
|
||||
// Reservierter Wert im Preset-Feld: „diesen Disc-Typ NICHT komprimieren".
|
||||
// Muss mit ripping.PRESET_KEINE im Worker übereinstimmen — es gibt kein
|
||||
// geteiltes Paket zwischen UI und Worker, deshalb steht der Wert hier nochmal.
|
||||
export const PRESET_KEINE = 'keine'
|
||||
|
||||
interface WorkerFuerBewertung {
|
||||
encoders?: string[]
|
||||
info?: { cpu_simd?: string }
|
||||
}
|
||||
|
||||
// --- Sprachnamen für die Auswahl vor dem Rip ---------------------------------
|
||||
//
|
||||
// MakeMKV liefert die Namen auf ENGLISCH („German", „Japanese") und für Spuren
|
||||
// ohne Sprach-Kennzeichnung „Undetermined". Beides an der Akira-Blu-ray gemessen
|
||||
// (26.07.2026): Ton deu 4×, jpn 2×, und 4× — die „und"-Spuren sind typischerweise
|
||||
// Audiokommentare, die kein Sprachfeld tragen.
|
||||
//
|
||||
// „Undetermined" versteht ein Nicht-Entwickler nicht, und der Commander liest
|
||||
// alles auf Deutsch (AGENTS Grundregel 4). Unbekannte Codes behalten MakeMKVs
|
||||
// Namen — geraten wird nichts.
|
||||
const SPRACHNAMEN: Record<string, string> = {
|
||||
und: 'Ohne Sprachangabe',
|
||||
deu: 'Deutsch', ger: 'Deutsch',
|
||||
eng: 'Englisch',
|
||||
jpn: 'Japanisch',
|
||||
fra: 'Französisch', fre: 'Französisch',
|
||||
spa: 'Spanisch',
|
||||
ita: 'Italienisch',
|
||||
nld: 'Niederländisch', dut: 'Niederländisch',
|
||||
rus: 'Russisch',
|
||||
kor: 'Koreanisch',
|
||||
zho: 'Chinesisch', chi: 'Chinesisch',
|
||||
pol: 'Polnisch',
|
||||
tur: 'Türkisch',
|
||||
ces: 'Tschechisch', cze: 'Tschechisch',
|
||||
por: 'Portugiesisch',
|
||||
swe: 'Schwedisch',
|
||||
dan: 'Dänisch',
|
||||
nor: 'Norwegisch',
|
||||
fin: 'Finnisch',
|
||||
hun: 'Ungarisch',
|
||||
ara: 'Arabisch',
|
||||
mul: 'Mehrsprachig',
|
||||
}
|
||||
|
||||
/** Sprachname auf Deutsch — sonst der Name, den MakeMKV geliefert hat. */
|
||||
export function sprachName(lang: string, makemkvName?: string): string {
|
||||
return SPRACHNAMEN[(lang || '').toLowerCase()] || makemkvName || lang || '?'
|
||||
}
|
||||
|
||||
// --- Erreicht ein EXTERNER Worker die Pfade? ---------------------------------
|
||||
//
|
||||
// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des
|
||||
// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und
|
||||
// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des
|
||||
// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie.
|
||||
// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier.
|
||||
|
||||
interface WorkerFuerPfadPruefung {
|
||||
name: string
|
||||
node?: string | null
|
||||
info?: { extern?: string, pfad_map?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie
|
||||
* `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`,
|
||||
* getrennt durch `;`, Treffer per Präfix.
|
||||
*/
|
||||
export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
|
||||
if (!pfad || !mapping) return false
|
||||
return mapping.split(';').some(paar => {
|
||||
const quelle = paar.split('=', 1)[0]
|
||||
return !!quelle && pfad.startsWith(quelle)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Namen der Speicherziele, die dieser Worker über sein Mapping erreicht.
|
||||
* Aus „/app/media/rippy=\\NAS\rippy" wird ["rippy"].
|
||||
*/
|
||||
export function freigabenAusMapping(mapping?: string): string[] {
|
||||
return (mapping || '').split(';')
|
||||
.map(p => p.split('=', 1)[0])
|
||||
.filter(Boolean)
|
||||
.map(p => p.replace(/^\/app\/media\//, ''))
|
||||
.filter(n => n && !n.includes('/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Warnt, BEVOR gerippt wird, wenn der gewählte Encoder die Pfade nicht
|
||||
* erreichen kann. null = alles in Ordnung (oder der Worker läuft im Container,
|
||||
* dann gibt es nichts zu übersetzen).
|
||||
*
|
||||
* `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw — ein Docker-Volume,
|
||||
* an das ein externer Worker grundsätzlich nicht herankommt.
|
||||
*/
|
||||
export function externWarnung(
|
||||
worker: WorkerFuerPfadPruefung | undefined,
|
||||
zielPfad: string,
|
||||
arbeitsPfad: string,
|
||||
): string | null {
|
||||
if (!worker || worker.info?.extern !== 'ja') return null
|
||||
const mapping = (worker.info?.pfad_map || '').trim()
|
||||
if (!mapping) {
|
||||
return `„${worker.name}" läuft auf einer anderen Maschine und hat keine `
|
||||
+ 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht '
|
||||
+ 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut '
|
||||
+ 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser '
|
||||
+ '„Automatisch" wählen.'
|
||||
}
|
||||
if (!arbeitsPfad) {
|
||||
return `„${worker.name}" arbeitet auf einer anderen Maschine. Das `
|
||||
+ 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist '
|
||||
+ 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als '
|
||||
+ 'Arbeitsverzeichnis wählen.'
|
||||
}
|
||||
const zielFehlt = !pfadAbgedeckt(zielPfad, mapping)
|
||||
const arbeitFehlt = !pfadAbgedeckt(arbeitsPfad, mapping)
|
||||
if (!zielFehlt && !arbeitFehlt) return null
|
||||
|
||||
/*
|
||||
* Die Meldung muss sagen, WAS ZU TUN IST — nicht nur, was klemmt.
|
||||
*
|
||||
* Commander-Rückmeldung 26.07.2026: „Das ist ja quatsch. Mein PC hat das Ziel
|
||||
* als Worker direkt auf dem PC eingebunden." Er hatte die Freigabe wirklich
|
||||
* als Netzlaufwerk gemountet — nur war das ARBEITSVERZEICHNIS darauf gelegt
|
||||
* und die ABLAGE nicht. Das Ziel lag weiter auf der VM-Platte
|
||||
* (/app/media/movies), und dorthin kommt sein PC nicht.
|
||||
*
|
||||
* Die alte Meldung war sachlich richtig und trotzdem unbrauchbar: Sie nannte
|
||||
* den Container-Pfad und die Mapping-Zeichenkette, also genau die zwei Dinge,
|
||||
* die ein Nicht-Entwickler nicht deuten kann. Jetzt steht der Handgriff drin,
|
||||
* inklusive des Namens der Freigabe, die dieser Worker wirklich erreicht.
|
||||
*/
|
||||
const freigabe = freigabenAusMapping(mapping)[0] || ''
|
||||
|
||||
const teile: string[] = []
|
||||
if (zielFehlt) {
|
||||
teile.push(`Die Ablage (${zielPfad}) liegt nicht auf der Freigabe.`)
|
||||
if (freigabe) {
|
||||
teile.push(`Abhilfe: Einstellungen → Ripping → „Ablage" auf `
|
||||
+ `„${freigabe}" stellen — das ist die Freigabe, die „${worker.name}" `
|
||||
+ 'wirklich erreicht.')
|
||||
}
|
||||
}
|
||||
if (arbeitFehlt) {
|
||||
teile.push(`Das Arbeitsverzeichnis (${arbeitsPfad || 'Container-Platte'}) `
|
||||
+ 'liegt nicht auf der Freigabe — oben umstellen.')
|
||||
}
|
||||
teile.push('Ohne das scheitert die Kompression (die Rohdaten bleiben erhalten).')
|
||||
return teile.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dasselbe Risiko bei „Automatisch": dort nimmt die geteilte Queue den ersten
|
||||
* freien Worker — auch einen externen, der die Pfade nicht erreicht. Nennt die
|
||||
* betroffenen Namen, damit man gezielt einen anderen wählt.
|
||||
*/
|
||||
export function automatikWarnung(
|
||||
workers: WorkerFuerPfadPruefung[] | undefined,
|
||||
zielPfad: string,
|
||||
arbeitsPfad: string,
|
||||
): string | null {
|
||||
const betroffen = (workers || [])
|
||||
.filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null)
|
||||
.map(w => w.name)
|
||||
if (betroffen.length === 0) return null
|
||||
return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann `
|
||||
+ `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. `
|
||||
+ 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste '
|
||||
+ 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
||||
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
||||
* eine bekannte SIMD-Stufe, und keiner erreicht AVX2 — und keiner hat einen
|
||||
* Hardware-Encoder, der die Frage sowieso erledigt.
|
||||
*
|
||||
* Bei unbekannter Stufe (z. B. Windows-Worker, dort gibt es kein
|
||||
* /proc/cpuinfo) wird NICHT gewarnt. Lieber schweigen als falsch warnen.
|
||||
*/
|
||||
export function schwacheEncoderCpu(workers?: WorkerFuerBewertung[]): boolean {
|
||||
const liste = workers || []
|
||||
if (liste.length === 0) return false
|
||||
const hardware = liste.some(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||
if (hardware) return false
|
||||
const bekannte = liste
|
||||
.map(w => w.info?.cpu_simd)
|
||||
.filter((s): s is string => !!s && s !== 'unbekannt')
|
||||
if (bekannte.length === 0) return false
|
||||
return !bekannte.some(s => SIMD_SCHNELL.includes(s))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die
|
||||
// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der
|
||||
// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung).
|
||||
export interface MediaServerOption {
|
||||
wert: string
|
||||
label: string
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [
|
||||
{ wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||
{ wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||
{ wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||
{ wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' },
|
||||
{ wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' },
|
||||
]
|
||||
@@ -2,12 +2,15 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
|
||||
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<Icon size={20} className="text-amber-500" />
|
||||
<span>{titel}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnleitungPage() {
|
||||
const fett = "font-semibold text-slate-100"
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
<PageHeader
|
||||
title="Anleitung & Hilfe"
|
||||
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
||||
/>
|
||||
|
||||
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
|
||||
<p>
|
||||
<span className={fett}>1. Disc einlegen.</span> Rippy erkennt sie automatisch (Titel, Poster,
|
||||
Beschreibung) und zeigt sie oben im Dashboard. Stimmt der Titel nicht → „Nicht korrekt?"
|
||||
klicken und den richtigen wählen — Rippy merkt sich das für diese Disc.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>2. „Rippen starten"</span> und das Ziel wählen: 🎥 Filme, 📺 Serien,
|
||||
🎵 Musik oder ein eigener Ordner (auch NAS-Freigaben). Optional: „Nur Hauptfilm" (längster
|
||||
Titel) oder per <span className={fett}>„Disc scannen"</span> einzelne Titel an-/abwählen.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>3. Warten.</span> Rippy sichert verlustfrei (MakeMKV) und komprimiert
|
||||
danach auf Arbeitsgröße (HandBrake) — alle Tonspuren und Untertitel bleiben erhalten.
|
||||
Fertige Jobs haben einen Download-Knopf; Klick auf den Titel zeigt alle Details.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Vollautomatik:</span> Unter Einstellungen → Ripping einschaltbar —
|
||||
dann startet der Rip ohne Nachfrage, sobald eine Disc erkannt wurde. Bereits gerippte
|
||||
Discs (Fingerabdruck) werden übersprungen.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Tv} titel="Serien rippen">
|
||||
<p>
|
||||
Im „Rippen starten"-Dialog <span className={fett}>„Serien"</span> wählen, Serienname und
|
||||
Staffel angeben. Die Ablage wird automatisch <code>Serie/Season 02/…</code>, und nach dem
|
||||
Rip ordnet Rippy die Episoden über einen Laufzeit-Abgleich mit TMDB zu und benennt sie
|
||||
(„Serie S02E03.mkv"). Das passiert nur bei <span className={fett}>eindeutiger</span> Zuordnung —
|
||||
im Zweifel bleiben die Original-Namen und das Log sagt warum.
|
||||
</p>
|
||||
<p>
|
||||
Eine komplette Staffel auf einer Disc klappt auch, wenn alle Episoden gleich lang sind
|
||||
(Anime-Boxen). Bei Teil-Discs mit lauter gleich langen Episoden ist eine automatische
|
||||
Zuordnung prinzipiell nicht möglich — dann von Hand umbenennen.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HardDrive} titel="NAS & Speicherziele">
|
||||
<p>
|
||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
||||
jedem Rip und bricht sonst mit Klartext ab.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Tv} titel="Jellyfin, Emby, Kodi, Plex">
|
||||
<p>
|
||||
Einstellungen → Ripping → Media-Server: Rippy benennt fertige Rips als „Titel (Jahr)" und
|
||||
legt für Jellyfin/Emby/Kodi zusätzlich <code>movie.nfo</code> + <code>poster.jpg</code> dazu —
|
||||
dein Server erkennt alles ohne Nacharbeit (Plex nutzt nur die Benennung).
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Null-Klick-Kette:</span> Bei Jellyfin/Emby zusätzlich Server-URL und
|
||||
API-Key eintragen (Administration → API-Schlüssel) — dann stößt Rippy nach jedem fertigen
|
||||
Rip sofort einen Bibliotheks-Scan an. Disc rein → Film erscheint im Server.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Bell} titel="Benachrichtigungen">
|
||||
<p>
|
||||
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen (Discord-Webhook, ntfy-Thema
|
||||
für Handy-Push, Slack oder eigenes JSON), „Test senden", speichern. Rippy meldet sich, wenn
|
||||
ein Job fertig ist, fehlschlägt oder abgebrochen wird. Die Schritt-für-Schritt-Anleitung je
|
||||
Dienst steht direkt auf der Einstellungs-Seite.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||
<p>
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
|
||||
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
|
||||
„Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
|
||||
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
|
||||
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
|
||||
</p>
|
||||
<p>
|
||||
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
||||
(Status, Start/Stopp, „Rippy öffnen", Log) — gestartet über <code>start-tray.bat</code>.
|
||||
Deinstallieren: <code>uninstall.ps1</code> im Worker-Ordner stoppt alles, entfernt den
|
||||
Autostart, meldet den Worker in Rippy ab und löscht den Ordner rückstandsfrei.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Wichtig:</span> Worker verbinden sich direkt im Heimnetz — in den
|
||||
Befehlen immer die <span className={fett}>LAN-Adresse</span> der Rippy-Maschine verwenden,
|
||||
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||
<p>
|
||||
Blu-ray-Ripping braucht den MakeMKV-Beta-Key, der etwa <span className={fett}>monatlich
|
||||
wechselt</span> (MakeMKV-Forum, Thread „MakeMKV is free while in beta"). Neuen Key unter
|
||||
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
|
||||
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
|
||||
</p>
|
||||
{/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
|
||||
<p>
|
||||
<span className={fett}>Updates:</span> „Auf Updates prüfen" (ebenfalls Einstellungen →
|
||||
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
|
||||
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
|
||||
Update-Befehl an — neue Versionen bringen bessere Laufwerks-Unterstützung und
|
||||
Fehlerbehebungen. <span className={fett}>Disc-Schlüssel für 4K-UHD kommen dagegen nicht
|
||||
aus einem Update</span> — die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
|
||||
nie. Wie du sie trotzdem bekommst, steht unter „Häufige Fragen".
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Download} titel="Fertige Dateien herunterladen">
|
||||
<p>
|
||||
Bei fertigen Jobs: „Download" in der Aktion-Spalte (oder Klick auf den Titel) → Dateiliste
|
||||
mit „Alle herunterladen". Die Job-Liste lässt sich per Papierkorb bzw. „Erledigte aufräumen"
|
||||
leeren — <span className={fett}>die gerippten Dateien bleiben dabei immer liegen</span>.
|
||||
„CSV" exportiert die komplette Historie für Excel.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HelpCircle} titel="Häufige Fragen">
|
||||
<p>
|
||||
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
||||
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
||||
</p>
|
||||
{/*
|
||||
25.07.2026 auf BEIDEN Maschinen nachgemessen und erneut korrigiert. Es
|
||||
stand hier nacheinander zweierlei Falsches: erst "ein MakeMKV-Update macht
|
||||
die Disc rippbar", dann "MakeMKVs Schluessel-Kanal ist tot". Richtig ist:
|
||||
makemkvcon unter Linux ruft die Schluessel nie ab, die Windows-Version
|
||||
schon (Meldung 3338, Verbindung nach 185.84.108.20:443).
|
||||
*/}
|
||||
<p>
|
||||
<span className={fett}>4K-UHD schlägt fehl mit „volume key is unknown"?</span> Das Laufwerk
|
||||
liest die Disc einwandfrei (LibreDrive) — MakeMKV fehlt nur der Schlüssel dieser Pressung.
|
||||
Der Grund liegt nicht bei dir und nicht bei Rippy: <span className={fett}>die Linux-Version
|
||||
von MakeMKV holt Disc-Schlüssel nie selbst aus dem Netz</span>. Die Windows-Version tut es.
|
||||
Ein MakeMKV-Update ändert daran nichts.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Der Weg drumherum:</span> MakeMKV einmalig auf einem Windows-PC
|
||||
installieren (gleicher Beta-Key), das Laufwerk dort anstecken, die Disc öffnen — MakeMKV
|
||||
lädt die Schlüssel dabei nach. Dann in MakeMKV unter <em>Preferences → General</em> das
|
||||
„MakeMKV data directory" nachschlagen und die Datei <code>_private_data.tar</code> daraus
|
||||
bei Rippy unter Einstellungen → System hochladen. Wirkt ab dem nächsten Rip. Für neue
|
||||
Discs gelegentlich wiederholen — der Block dort zeigt dir, wie viele Schlüssel Rippy kennt.
|
||||
</p>
|
||||
<p>
|
||||
Geht eine Pressung auch damit nicht auf, kennt MakeMKV sie selbst nicht. Dann bleiben zwei
|
||||
Dinge: eine <code>KEYDB.cfg</code> (ebenfalls dort hochladbar, der Notnagel), oder den
|
||||
<span className={fett}> AACS-Dump</span> im MakeMKV-Forum im Bereich „Ultra HD Blu-ray"
|
||||
einreichen — der bleibt jetzt erhalten und steht unter Einstellungen → System zum
|
||||
Herunterladen. <span className={fett}>Rippy liefert keine Schlüssel mit und lädt keine
|
||||
herunter</span> — es verwaltet nur, was du selbst mitbringst.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
||||
erscheint nur, wenn die Rohdaten noch daliegen — bei einem Job, der nie gerippt hat, gäbe
|
||||
es nichts zu komprimieren.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Kein Laufwerk sichtbar?</span> In einer VM muss das Laufwerk per
|
||||
USB-Passthrough durchgereicht sein (nicht als emuliertes CD-ROM) — Details in der README.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+844
-238
File diff suppressed because it is too large
Load Diff
+126
-95
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
@@ -16,18 +19,21 @@ export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { theme } = useDarkMode()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const refreshLogs = async () => {
|
||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
||||
const refreshLogs = async (manuell = false) => {
|
||||
if (manuell) setRefreshing(true)
|
||||
try {
|
||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||
setLogs(response.data)
|
||||
if (manuell) toast('success', 'Logs aktualisiert')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Logs:', error)
|
||||
if (manuell) toast('error', 'Logs konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (manuell) setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +45,19 @@ export default function LogsPage() {
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20'
|
||||
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20'
|
||||
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20'
|
||||
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20'
|
||||
case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||
case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||
case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||
default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error': return <AlertCircle size={16} />
|
||||
case 'warning': return <AlertCircle size={16} />
|
||||
case 'success': return <CheckCircle size={16} />
|
||||
default: return <Terminal size={16} />
|
||||
case 'error': return <AlertCircle size={15} />
|
||||
case 'warning': return <AlertCircle size={15} />
|
||||
case 'success': return <CheckCircle size={15} />
|
||||
default: return <Terminal size={15} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +74,24 @@ export default function LogsPage() {
|
||||
? logs
|
||||
: logs.filter(log => log.level === filter || log.source === filter)
|
||||
|
||||
/*
|
||||
* Quellen zum Anklicken (Commander-Wunsch 26.07.2026: „bessere Log-Ansichten
|
||||
* — kein txt file, direkt von Rippy Logs").
|
||||
*
|
||||
* Der Filter konnte Quellen schon immer, es gab nur keinen Knopf dafür — man
|
||||
* musste die Zeilen mit dem Auge durchsuchen. Seit dieser Runde meldet auch
|
||||
* der externe Windows-Worker hierher (worker/logbruecke.py, Quelle „w:<name>"),
|
||||
* und damit wird die Auswahl erst richtig nützlich: „was macht mein PC?" ist
|
||||
* ein Klick.
|
||||
*
|
||||
* Die Liste wird aus den WIRKLICH vorhandenen Zeilen gebaut, nicht aus einer
|
||||
* festen Aufzählung — sonst fehlt jede neue Maschine.
|
||||
*/
|
||||
const quellen = [...new Set(logs.map(l => l.source).filter(Boolean))].sort()
|
||||
|
||||
// „w:tobisnicerpc" ist als Knopfbeschriftung unschön.
|
||||
const quelleLabel = (q: string) => q.startsWith('w:') ? `Worker ${q.slice(2)}` : q
|
||||
|
||||
const stats = {
|
||||
info: logs.filter(l => l.level === 'info').length,
|
||||
warning: logs.filter(l => l.level === 'warning').length,
|
||||
@@ -76,133 +100,140 @@ export default function LogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 transition-colors duration-300">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p>
|
||||
</div>
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
title="System-Logs"
|
||||
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
||||
action={
|
||||
<Button variant="secondary" onClick={() => refreshLogs(true)}>
|
||||
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||
Neu laden
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
|
||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
|
||||
<Terminal size={18} />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span>
|
||||
<div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
|
||||
<AlertCircle size={18} />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span>
|
||||
<div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span>
|
||||
<div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
|
||||
<AlertCircle size={18} />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p>
|
||||
<div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span>
|
||||
{/* Filters & Log List */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
|
||||
{['all', 'info', 'success', 'warning', 'error'].map(f => (
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === 'all'
|
||||
? 'bg-indigo-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'
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||
filter === f
|
||||
? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/20'
|
||||
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Alle
|
||||
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{quellen.length > 1 && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mx-2">Quelle:</span>
|
||||
{quellen.map(q => (
|
||||
<button
|
||||
onClick={() => setFilter('info')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === 'info'
|
||||
? 'bg-blue-600 text-white'
|
||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
key={q}
|
||||
onClick={() => setFilter(q)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||
filter === q
|
||||
? 'bg-indigo-500 text-white font-bold shadow-md shadow-indigo-500/20'
|
||||
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Info
|
||||
{quelleLabel(q)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('success')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === 'success'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Erfolg
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={refreshLogs}
|
||||
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' : ''} />
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs List */}
|
||||
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="text-center py-12 transition-colors duration-300">
|
||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
|
||||
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
||||
<div className="text-center py-16">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||
<Terminal size={32} />
|
||||
</div>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<div className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
||||
{filteredLogs.map(log => (
|
||||
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}>
|
||||
<div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||
{log.source}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
{log.timestamp}
|
||||
</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
|
||||
{getLevelLabel(log.level)}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{log.message}
|
||||
</p>
|
||||
{log.details && (
|
||||
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
||||
<p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
|
||||
{log.details}
|
||||
</p>
|
||||
)}
|
||||
@@ -213,7 +244,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
interface DeviceOption {
|
||||
path: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
backdrop_path?: string
|
||||
runtime?: number
|
||||
genres?: string[]
|
||||
}
|
||||
|
||||
interface Track {
|
||||
track_number: number
|
||||
title: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface PrescanResult {
|
||||
disc_type: 'cd' | 'dvd' | 'bluray'
|
||||
title: string
|
||||
year?: number
|
||||
confidence: number
|
||||
metadata?: Metadata
|
||||
tracks?: Track[]
|
||||
}
|
||||
|
||||
export default function MetadataPreview() {
|
||||
const [device, setDevice] = useState<string>('')
|
||||
const [devices, setDevices] = useState<DeviceOption[]>([])
|
||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
|
||||
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
||||
useEffect(() => {
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const response = await api.get('/devices')
|
||||
setDevices(response.data)
|
||||
if (response.data.length === 1) {
|
||||
setDevice(response.data[0].path)
|
||||
}
|
||||
} catch {
|
||||
setDevices([])
|
||||
}
|
||||
}
|
||||
loadDevices()
|
||||
const interval = setInterval(loadDevices, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!device) {
|
||||
setError('Bitte wählen Sie ein Gerät aus.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.post('/prescan', { device_path: device })
|
||||
setResult(response.data)
|
||||
} catch (err) {
|
||||
setError('Pre-Scan fehlgeschlagen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!result) return
|
||||
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
await api.post('/metadata/confirm', {
|
||||
title: result.title,
|
||||
year: result.year,
|
||||
metadata: result.metadata
|
||||
})
|
||||
|
||||
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
|
||||
await api.post('/jobs', {
|
||||
device_path: device,
|
||||
title: result.title
|
||||
})
|
||||
} catch (err) {
|
||||
setError('Bestätigung fehlgeschlagen.')
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = (type: string): LucideIcon => {
|
||||
switch (type) {
|
||||
case 'cd':
|
||||
return Music
|
||||
case 'dvd':
|
||||
case 'bluray':
|
||||
return Film
|
||||
default:
|
||||
return BookOpen
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceColor = (confidence: number): string => {
|
||||
if (confidence >= 0.9) return theme === 'dark' ? 'text-green-400 bg-green-900/30' : 'text-green-600 bg-green-100'
|
||||
if (confidence >= 0.7) return theme === 'dark' ? 'text-yellow-400 bg-yellow-900/30' : 'text-yellow-600 bg-yellow-100'
|
||||
return theme === 'dark' ? 'text-red-400 bg-red-900/30' : 'text-red-600 bg-red-100'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto transition-colors duration-300">
|
||||
<div className="mb-8 transition-colors duration-300">
|
||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Metadaten-Preview</h1>
|
||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
<div className="flex gap-4 transition-colors duration-300">
|
||||
<select
|
||||
value={device}
|
||||
onChange={(e) => setDevice(e.target.value)}
|
||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
|
||||
>
|
||||
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
||||
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
|
||||
</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
||||
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={!device || loading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white transition-colors ${
|
||||
!device || loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Scannen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={20} />
|
||||
Scan starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 transition-colors duration-300 ${theme === 'dark' ? 'bg-red-900/30 border border-red-700 text-red-400' : 'bg-red-50 border border-red-200 text-red-700'}`}>
|
||||
<AlertCircle size={24} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getIcon(result.disc_type)({ size: 32 })}
|
||||
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
||||
{result.disc_type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold">{result.title}</h2>
|
||||
{result.year && (
|
||||
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence Score */}
|
||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-b border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2 transition-colors duration-300">
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>Übereinstimmung:</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${getConfidenceColor(result.confidence)}`}>
|
||||
{Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="p-6 transition-colors duration-300">
|
||||
{result.metadata && (
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
{result.metadata.overview && (
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Beschreibung</h3>
|
||||
<p className={`leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{result.metadata.overview}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.genres && (
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
|
||||
<div className="flex flex-wrap gap-2 transition-colors duration-300">
|
||||
{result.metadata.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.runtime && (
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
|
||||
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
{result.tracks && result.tracks.length > 0 && (
|
||||
<div className="mt-6 transition-colors duration-300">
|
||||
<h3 className={`text-sm font-medium uppercase mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Tracks</h3>
|
||||
<div className="space-y-2 transition-colors duration-300">
|
||||
{result.tracks.map((track, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 transition-colors duration-300">
|
||||
<div className="flex items-center gap-3 transition-colors duration-300">
|
||||
<span className={`w-8 font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>{track.track_number}</span>
|
||||
<span className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-gray-900'}`}>{track.title}</span>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 border-t border-slate-700' : 'bg-gray-50 border-t border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between transition-colors duration-300">
|
||||
<div className="flex items-center gap-3 text-sm transition-colors duration-300">
|
||||
<CheckCircle className={theme === 'dark' ? 'text-green-400' : 'text-green-600'} size={20} />
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-gray-600'}>Bereit zum Ripping</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Bestätigen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={20} />
|
||||
Rippen starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1323
-215
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,39 @@
|
||||
|
||||
FROM python:3.12-slim-bookworm AS makemkv-build
|
||||
|
||||
# Zurück auf die AKTUELLE Version (24.07.2026): 1.17.7 war wegen des
|
||||
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
|
||||
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
|
||||
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
|
||||
# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt).
|
||||
# Richtigstellung 25.07.2026: Hier stand, die aktuelle Version zähle, weil sie
|
||||
# "die neueste AACS-Schlüssel-Datenbank mitbringt". Das ist widerlegt — MakeMKV
|
||||
# bringt gar keine Disc-Schlüssel mit, und der Online-Kanal liefert nichts mehr
|
||||
# (Messungen im Modul-Kopf von makemkv_daten.py). Aktuell bleiben lohnt sich
|
||||
# trotzdem: Laufwerks-Unterstützung und Fehlerbehebungen. Schlüssel für neue
|
||||
# UHD-Pressungen kommen ausschließlich aus der KEYDB.cfg im Datenverzeichnis.
|
||||
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
|
||||
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
|
||||
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
|
||||
# konsequent HTTP-Fehler), Tarballs von Hand laden und lokal servieren:
|
||||
# mkdir ~/makemkv-mirror && curl -o … && docker run -d --name makemkv-mirror \
|
||||
# -p 8099:80 -v ~/makemkv-mirror:/usr/share/nginx/html:ro nginx:alpine
|
||||
# docker compose build --build-arg MAKEMKV_URL_BASE=http://<host-ip>:8099 worker
|
||||
ARG MAKEMKV_VERSION=1.18.4
|
||||
ARG MAKEMKV_URL_BASE=https://www.makemkv.com/download
|
||||
# Zweite Quelle, die bei einem Fehlschlag der ersten AUTOMATISCH probiert wird.
|
||||
# Absichtlich LEER: Am 25.07.2026 nachgemessen, welche Quellen wirklich liefern —
|
||||
# https://www.makemkv.com/download HTTP 200 (der Standard oben)
|
||||
# https://www.makemkv.com/download/old HTTP 525 (Cloudflare)
|
||||
# web.archive.org-Schnappschuss HTTP 404
|
||||
# Es gibt derzeit also keine belegbare zweite Quelle. Eine hier einzutragen, die
|
||||
# nicht liefert, wäre schlimmer als keine: der Build scheitert dann später und
|
||||
# die Konfiguration sieht trotzdem gesund aus — genau das war auf der Rippy-VM
|
||||
# der Fall (dort stand die 404-Adresse, und nur die vendor-Tarballs retteten
|
||||
# jeden Build, ohne dass es auffiel).
|
||||
# Wer eine eigene Quelle hat (Spiegel im LAN, eigener Webserver), traegt sie ein:
|
||||
# docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker
|
||||
ARG MAKEMKV_URL_FALLBACK=
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
@@ -27,10 +59,47 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
||||
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \
|
||||
&& tar xzf oss.tar.gz \
|
||||
&& tar xzf bin.tar.gz
|
||||
# Download mit DREIFACHEM Netz (Cloudflare drosselt BuildKit-Downloads
|
||||
# hartnäckig — 24.07. viermal, auch MIT --retry-all-errors):
|
||||
# 1. Liegen die Tarballs lokal in docker/worker/vendor/ (untracked), werden
|
||||
# SIE genutzt — einmal von Hand hinlegen, nie wieder Download-Roulette.
|
||||
# 2. Sonst curl mit Retries gegen MAKEMKV_URL_BASE.
|
||||
# 3. Scheitert das, wird MAKEMKV_URL_FALLBACK versucht, falls gesetzt.
|
||||
# Und wenn alles scheitert, sagt die Meldung, welche Wege es gibt — statt nur
|
||||
# einen curl-Rückgabewert zu hinterlassen.
|
||||
COPY docker/worker/vendor/ ./vendor/
|
||||
RUN set -e; \
|
||||
hole() { \
|
||||
ziel="$1"; datei="$2"; \
|
||||
if curl -fsSL --retry 5 --retry-delay 15 --retry-all-errors \
|
||||
-o "$ziel" "${MAKEMKV_URL_BASE}/${datei}"; then \
|
||||
return 0; \
|
||||
fi; \
|
||||
if [ -n "${MAKEMKV_URL_FALLBACK}" ]; then \
|
||||
echo "Erste Quelle lieferte nicht — versuche MAKEMKV_URL_FALLBACK"; \
|
||||
curl -fsSL --retry 3 --retry-delay 10 --retry-all-errors \
|
||||
-o "$ziel" "${MAKEMKV_URL_FALLBACK}/${datei}" && return 0; \
|
||||
fi; \
|
||||
echo "FEHLER: ${datei} war von keiner Quelle zu holen." >&2; \
|
||||
echo " Geprueft: ${MAKEMKV_URL_BASE}${MAKEMKV_URL_FALLBACK:+ und ${MAKEMKV_URL_FALLBACK}}" >&2; \
|
||||
echo " Sicherster Weg: Tarballs von makemkv.com/download herunterladen" >&2; \
|
||||
echo " und nach docker/worker/vendor/ legen, dann erneut bauen." >&2; \
|
||||
echo " Alternativ eine eigene Quelle angeben:" >&2; \
|
||||
echo " docker compose build --build-arg MAKEMKV_URL_FALLBACK=http://<host>:8099 worker" >&2; \
|
||||
return 1; \
|
||||
}; \
|
||||
if [ -s "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" ] \
|
||||
&& [ -s "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" ]; then \
|
||||
echo "Nutze lokale Tarballs aus vendor/"; \
|
||||
cp "vendor/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" oss.tar.gz; \
|
||||
cp "vendor/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" bin.tar.gz; \
|
||||
else \
|
||||
hole oss.tar.gz "makemkv-oss-${MAKEMKV_VERSION}.tar.gz"; \
|
||||
hole bin.tar.gz "makemkv-bin-${MAKEMKV_VERSION}.tar.gz"; \
|
||||
fi; \
|
||||
sha256sum oss.tar.gz bin.tar.gz; \
|
||||
tar xzf oss.tar.gz; \
|
||||
tar xzf bin.tar.gz
|
||||
|
||||
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
||||
&& ./configure --disable-gui --prefix=/usr/local \
|
||||
@@ -50,6 +119,8 @@ RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip
|
||||
# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
libexpat1 \
|
||||
@@ -59,11 +130,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cdparanoia \
|
||||
cd-discid \
|
||||
flac \
|
||||
handbrake-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=makemkv-build /usr/local /usr/local
|
||||
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.18.4
|
||||
ENV MAKEMKV_VERSION=${MAKEMKV_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/worker/requirements.txt .
|
||||
@@ -73,4 +150,6 @@ COPY docker/worker/ .
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
|
||||
# Der lokale Worker bedient BEIDE Queues (rip + transcode). Ein optionaler
|
||||
# Remote-GPU-Worker startet dasselbe Image nur mit "-Q transcode".
|
||||
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info", "-Q", "celery,transcode"]
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Encoder-Fähigkeiten dieses Workers — ehrlich erkannt, nicht behauptet.
|
||||
|
||||
Jeder Worker meldet beim Start, was auf SEINER Maschine wirklich verfügbar
|
||||
ist (Commander-Anforderung 23.07.: das UI zeigt an, WAS da ist). Ein
|
||||
Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from winlauf import OHNE_FENSTER
|
||||
|
||||
|
||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||
|
||||
# Reihenfolge = Anzeige-Reihenfolge im UI.
|
||||
SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2")
|
||||
|
||||
|
||||
def parse_encoder_liste(hilfe_text: str) -> list:
|
||||
"""Video-Encoder, die DIESES HandBrake wirklich anbietet.
|
||||
|
||||
Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder <string>`
|
||||
(AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten):
|
||||
|
||||
-e, --encoder <string> Select video encoder:
|
||||
svt_av1
|
||||
x264
|
||||
x265
|
||||
...
|
||||
--encoder-preset <string>
|
||||
|
||||
Der Block endet an der nächsten Option — also an der ersten Zeile, deren
|
||||
erstes nicht-leeres Zeichen ein Bindestrich ist.
|
||||
"""
|
||||
encoder = []
|
||||
im_block = False
|
||||
for zeile in (hilfe_text or "").splitlines():
|
||||
inhalt = zeile.strip()
|
||||
if not im_block:
|
||||
if HB_ENCODER_KOPF.match(inhalt):
|
||||
im_block = True
|
||||
continue
|
||||
if not inhalt:
|
||||
continue
|
||||
if inhalt.startswith("-"):
|
||||
break
|
||||
encoder.append(inhalt.split()[0])
|
||||
return encoder
|
||||
|
||||
|
||||
# Hardware-Encoder-Familien, wie HandBrake sie benennt → Backend-Kennung.
|
||||
# Reihenfolge = Anzeige-Reihenfolge; AV1 zuerst, weil es das modernste ist.
|
||||
HW_FAMILIEN = (
|
||||
("nvenc", "nvenc"), # NVIDIA
|
||||
("qsv", "qsv"), # Intel QuickSync
|
||||
("vce", "vce"), # AMD VCE/VCN
|
||||
("vaapi", "vaapi"), # generisch (Linux)
|
||||
)
|
||||
|
||||
|
||||
def leite_backends_ab(hb_encoder) -> list:
|
||||
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
|
||||
|
||||
## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig
|
||||
|
||||
Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri`
|
||||
für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch:
|
||||
|
||||
1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT)
|
||||
meldete deshalb nur CPU-Encoder, obwohl HandBrakes Windows-Build
|
||||
`vce_*`, `nvenc_*` und `qsv_*` beherrscht. Ein Bug, der genau den
|
||||
Anwendungsfall traf, für den externe Worker gedacht sind.
|
||||
2. **Die Prüfung war überflüssig.** HandBrake probiert Hardware-Encoder
|
||||
beim Start selbst an und listet nur, was es wirklich benutzen kann. Auf
|
||||
der Rippy-VM belegt: `--version` meldet „qsv: not available on this
|
||||
system" und „Cannot load libnvidia-encode.so.1" — und genau diese
|
||||
Encoder fehlen dann in `--help`. Wer die Liste hat, hat die Antwort.
|
||||
|
||||
Bleibt die eine Regel, die weiter gilt: **ohne HandBrake gibt es keine
|
||||
Encoder.** Vorher standen `cpu-x264`/`cpu-x265` fest verdrahtet drin, und
|
||||
ein reiner Rip-Worker behauptete damit, komprimieren zu können.
|
||||
|
||||
Hardware wird jetzt nach Familie unterschieden (nvenc/qsv/vce/vaapi) statt
|
||||
alles in „vaapi" zu werfen — sonst kann das UI nicht sagen, welches Preset
|
||||
passt, und AMD-Karten liefen unter einem Intel-Namen.
|
||||
"""
|
||||
namen = {e.lower() for e in (hb_encoder or [])}
|
||||
gefunden = []
|
||||
if any(n.startswith("x264") for n in namen):
|
||||
gefunden.append("cpu-x264")
|
||||
if any(n.startswith("x265") for n in namen):
|
||||
gefunden.append("cpu-x265")
|
||||
if any(n.startswith("svt_av1") for n in namen):
|
||||
gefunden.append("cpu-av1")
|
||||
for kennzeichen, backend in HW_FAMILIEN:
|
||||
treffer = [n for n in namen if kennzeichen in n]
|
||||
if not treffer:
|
||||
continue
|
||||
gefunden.append(backend)
|
||||
# AV1 in Hardware ist die derzeit beste Kombination aus Tempo und
|
||||
# Dateigröße — das muss sichtbar sein, nicht in „nvenc" verschwinden.
|
||||
if any("av1" in n for n in treffer):
|
||||
gefunden.append(f"{backend}-av1")
|
||||
return gefunden
|
||||
|
||||
|
||||
def parse_preset_liste(text: str) -> list:
|
||||
"""Preset-NAMEN, die DIESES HandBrake wirklich kennt.
|
||||
|
||||
Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image
|
||||
gemessen (HandBrake 1.6.1) — AGENTS Regel D, nicht aus dem Kopf:
|
||||
|
||||
General/
|
||||
Very Fast 2160p60 4K AV1
|
||||
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||
container.
|
||||
Hardware/
|
||||
H.265 VCN 2160p 4K
|
||||
|
||||
Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit „/" am
|
||||
Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht.
|
||||
HandBrakes Logzeilen („[10:26:05] qsv: not available on this system") und
|
||||
Meldungen wie „Cannot load libnvidia-encode.so.1" haben keine Einrückung
|
||||
und fallen damit von selbst weg.
|
||||
|
||||
⚠️ Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der
|
||||
HARDWARE-Presets seien auf der Rippy-VM „nicht ermittelbar", weil deren
|
||||
HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch — die
|
||||
Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF).
|
||||
HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets,
|
||||
`--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für
|
||||
„welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das
|
||||
hier" die Encoder-Liste. Deshalb werden beide gemeldet.
|
||||
"""
|
||||
presets = []
|
||||
for zeile in (text or "").splitlines():
|
||||
if not zeile.strip():
|
||||
continue
|
||||
einrueckung = len(zeile) - len(zeile.lstrip(" "))
|
||||
if einrueckung == 4:
|
||||
presets.append(zeile.strip())
|
||||
return presets
|
||||
|
||||
|
||||
def hole_handbrake_presets() -> list:
|
||||
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
||||
if not shutil.which("HandBrakeCLI"):
|
||||
return []
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--preset-list"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
|
||||
|
||||
def beste_simd(flags_text: str) -> str:
|
||||
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
|
||||
|
||||
Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell
|
||||
(„QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2.
|
||||
x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene
|
||||
28-55 Stunden. Sichtbar war das nirgends — genau diese Lücke schließt die
|
||||
Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen.
|
||||
"""
|
||||
vorhanden = set((flags_text or "").split())
|
||||
for stufe in SIMD_STUFEN:
|
||||
if stufe in vorhanden:
|
||||
return stufe
|
||||
return "unbekannt"
|
||||
|
||||
|
||||
def _lies_cpuinfo() -> tuple:
|
||||
"""(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows."""
|
||||
modell, flags, kerne = "", "", 0
|
||||
try:
|
||||
with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f:
|
||||
for zeile in f:
|
||||
if zeile.startswith("processor"):
|
||||
kerne += 1
|
||||
elif not modell and zeile.startswith("model name"):
|
||||
modell = zeile.split(":", 1)[-1].strip()
|
||||
elif not flags and zeile.startswith("flags"):
|
||||
flags = zeile.split(":", 1)[-1].strip()
|
||||
except OSError:
|
||||
pass
|
||||
return modell, flags, kerne
|
||||
|
||||
|
||||
# Merkmals-Nummern für IsProcessorFeaturePresent (Win32, winnt.h).
|
||||
# Absteigend nach Stärke — der erste Treffer ist die höchste Stufe.
|
||||
WIN_MERKMALE = (
|
||||
("avx512f", 41), # PF_AVX512F_INSTRUCTIONS_AVAILABLE
|
||||
("avx2", 40), # PF_AVX2_INSTRUCTIONS_AVAILABLE
|
||||
("avx", 39), # PF_AVX_INSTRUCTIONS_AVAILABLE
|
||||
("sse4_2", 38), # PF_SSE4_2_INSTRUCTIONS_AVAILABLE
|
||||
)
|
||||
|
||||
|
||||
def _simd_windows() -> str:
|
||||
"""Höchste Vektor-Befehlsstufe unter Windows.
|
||||
|
||||
Es gibt dort kein /proc/cpuinfo, deshalb meldete der Windows-Worker bis
|
||||
25.07.2026 „Vektorbefehle unbekannt" — gerade auf der Maschine, die das
|
||||
Encodieren übernehmen soll, also genau da, wo die Angabe zählt.
|
||||
|
||||
Gefragt wird `IsProcessorFeaturePresent` aus kernel32 (Win32-API, in
|
||||
winnt.h dokumentiert). Kein Zusatzpaket, kein Registry-Gefummel, keine
|
||||
Fremdprozesse.
|
||||
"""
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return "unbekannt"
|
||||
for name, nummer in WIN_MERKMALE:
|
||||
try:
|
||||
if kernel32.IsProcessorFeaturePresent(nummer):
|
||||
return name
|
||||
except OSError:
|
||||
return "unbekannt"
|
||||
return "unbekannt"
|
||||
|
||||
|
||||
def _modell_windows() -> str:
|
||||
"""Lesbarer CPU-Name unter Windows.
|
||||
|
||||
platform.processor() liefert dort nur „AMD64 Family 26 Model 68 …" — die
|
||||
Registry kennt den Namen, den auch der Aufkleber trägt.
|
||||
"""
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
) as schluessel:
|
||||
name, _ = winreg.QueryValueEx(schluessel, "ProcessorNameString")
|
||||
return " ".join(str(name).split())
|
||||
except (ImportError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def cpu_merkmale() -> tuple:
|
||||
"""(Modell, SIMD-Stufe, Kernzahl) — plattformübergreifend, nie leer."""
|
||||
modell, flags, kerne = _lies_cpuinfo()
|
||||
simd = beste_simd(flags)
|
||||
if simd == "unbekannt" and platform.system() == "Windows":
|
||||
simd = _simd_windows()
|
||||
if not modell:
|
||||
modell = _modell_windows() or platform.processor() or "unbekannt"
|
||||
return modell, simd, (kerne or os.cpu_count() or 0)
|
||||
|
||||
|
||||
def hole_handbrake_hilfe() -> str:
|
||||
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||
if not shutil.which("HandBrakeCLI"):
|
||||
return ""
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER
|
||||
)
|
||||
return (aus.stdout or "") + (aus.stderr or "")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def erkenne_encoder() -> list:
|
||||
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.
|
||||
|
||||
Gefragt wird ausschließlich HandBrake selbst — es probiert Hardware beim
|
||||
Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre
|
||||
plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab.
|
||||
"""
|
||||
return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe()))
|
||||
|
||||
|
||||
def erkenne_ip() -> str:
|
||||
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
|
||||
|
||||
Ehrlicher Vorbehalt: im Docker-Bridge-Netz ist das die Container-IP —
|
||||
für die Maschinen-Zuordnung ist deshalb WORKER_NAME (Anzeigename in
|
||||
compose/.env) die verlässliche Größe; die IP ist Zusatz-Indiz.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("192.0.2.1", 9)) # TEST-NET — nur Routing, kein Paket nötig
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
import socket
|
||||
|
||||
modell, simd, kerne = cpu_merkmale()
|
||||
info = {
|
||||
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
||||
"hostname": socket.gethostname(),
|
||||
"ip": erkenne_ip(),
|
||||
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
|
||||
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
|
||||
# Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||
"cpu_modell": modell,
|
||||
"cpu_kerne": str(kerne),
|
||||
"cpu_simd": simd,
|
||||
# Läuft dieser Worker INNERHALB des Rippy-Containers oder auf einer
|
||||
# fremden Maschine? Entscheidend, weil ein externer Worker die
|
||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
||||
"extern": "nein" if os.path.isdir("/app") else "ja",
|
||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||
# grundsätzlich nicht komprimieren.
|
||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||
}
|
||||
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
|
||||
if hb_encoder:
|
||||
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
||||
# woher die Backend-Liste kommt.
|
||||
info["handbrake_encoder"] = ",".join(hb_encoder)
|
||||
# Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet
|
||||
# genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich
|
||||
# zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression
|
||||
# scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine
|
||||
# echte Liste, nicht eine geratene).
|
||||
presets = hole_handbrake_presets()
|
||||
if presets:
|
||||
info["presets"] = presets
|
||||
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,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
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"
|
||||
# Sieht DIESER Worker eine KEYDB.cfg? Die API zeigt ihren eigenen Mount —
|
||||
# bei einem Remote-Worker kann das etwas ganz anderes sein, und nur das
|
||||
# Verzeichnis des rippenden Workers zählt (Befund 25.07.2026).
|
||||
#
|
||||
# Gemeldet wird die Angabe NUR, wenn das Datenverzeichnis hier wirklich
|
||||
# eingehängt ist (os.path.ismount). Ein Remote-Transcode-Worker benutzt
|
||||
# dasselbe Image — makemkvcon ist dort also vorhanden und der entrypoint
|
||||
# legt den Ordner an —, er bekommt den Mount aber nicht und rippt nie.
|
||||
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
|
||||
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
|
||||
try:
|
||||
import makemkv_daten
|
||||
daten_dir = makemkv_daten.DATEN_DIR
|
||||
except Exception:
|
||||
daten_dir = ""
|
||||
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
||||
try:
|
||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||
except Exception:
|
||||
info["keydb"] = "unbekannt"
|
||||
# Die entscheidende Zahl für 4K-UHD: wie viele Disc-Schlüssel kennt
|
||||
# dieser Worker? 0 heißt, dass jede unbekannte UHD-Disc scheitert —
|
||||
# makemkvcon holt sie unter Linux nie selbst (Befund 25.07.2026).
|
||||
try:
|
||||
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
|
||||
except Exception:
|
||||
info["schluessel"] = "unbekannt"
|
||||
|
||||
# Version für den Windows-Worker (aus version.txt) oder Linux-Worker (aus Env)
|
||||
try:
|
||||
ver_pfad = os.path.join(os.path.dirname(__file__), "version.txt")
|
||||
if os.path.isfile(ver_pfad):
|
||||
with open(ver_pfad, "r", encoding="utf-8") as f:
|
||||
info["rippy_version"] = f.read().strip()
|
||||
else:
|
||||
info["rippy_version"] = os.getenv("RIPPY_VERSION", "dev")
|
||||
except Exception:
|
||||
info["rippy_version"] = "unbekannt"
|
||||
|
||||
return info
|
||||
@@ -1,5 +1,8 @@
|
||||
from celery import Celery
|
||||
import os
|
||||
import socket
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import worker_ready
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
@@ -16,4 +19,72 @@ celery_app.conf.update(
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
broker_connection_retry_on_startup=True,
|
||||
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
|
||||
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
|
||||
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
|
||||
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
|
||||
# transcode-Queue (irgendein freier Worker).
|
||||
worker_direct=True,
|
||||
)
|
||||
|
||||
|
||||
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
|
||||
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
||||
import caps # noqa: E402
|
||||
import db # noqa: E402
|
||||
import zombies # noqa: E402
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def raeume_job_leichen_auf(**kwargs):
|
||||
"""Nach der Gnadenfrist: Jobs, an denen niemand arbeitet, ehrlich auf
|
||||
'failed' setzen (Details und Sicherungen in zombies.py).
|
||||
|
||||
Läuft im Hintergrund-Thread — der Worker soll sofort Aufgaben annehmen und
|
||||
nicht zwei Minuten auf die Aufräumung warten.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
def spaeter():
|
||||
time.sleep(zombies.GNADENFRIST_SEKUNDEN)
|
||||
try:
|
||||
db.init_db()
|
||||
bericht = zombies.raeume_zombies_auf(celery_app, db)
|
||||
print(f"Zombie-Erkennung: {bericht}")
|
||||
except Exception as e: # darf den Worker nie mitnehmen
|
||||
print(f"Zombie-Erkennung fehlgeschlagen: {e}")
|
||||
|
||||
threading.Thread(target=spaeter, daemon=True, name="zombie-erkennung").start()
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def melde_faehigkeiten(**kwargs):
|
||||
"""Beim Start + minütlich: Encoder-Fähigkeiten und Lebenszeichen melden.
|
||||
|
||||
last_seen ist die Basis der Erreichbarkeits-Anzeige im UI — ein Worker,
|
||||
der sich 2 Minuten nicht meldet, gilt als offline.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Anzeigename: WORKER_NAME (compose/.env) macht die Maschine im UI
|
||||
# zuordenbar UND stabil — der Container-Hostname wechselt bei jedem
|
||||
# Rebuild und hinterließ Offline-Leichen in der Worker-Liste.
|
||||
anzeigename = os.getenv("WORKER_NAME") or socket.gethostname()
|
||||
|
||||
def herzschlag():
|
||||
while True:
|
||||
try:
|
||||
db.init_db()
|
||||
db.save_worker(
|
||||
anzeigename,
|
||||
caps.erkenne_encoder(),
|
||||
caps.werkzeug_versionen(),
|
||||
)
|
||||
except Exception as e: # DB weg → weiterversuchen, nicht sterben
|
||||
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
threading.Thread(target=herzschlag, daemon=True, name="worker-herzschlag").start()
|
||||
|
||||
+189
-1
@@ -36,7 +36,9 @@ jobs = Table(
|
||||
Column("status", String(16), nullable=False, server_default="pending"),
|
||||
Column("progress", Integer, nullable=False, server_default="0"),
|
||||
Column("output_path", Text),
|
||||
Column("target_dir", String(255)),
|
||||
Column("error", Text),
|
||||
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
|
||||
Column("created_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
)
|
||||
@@ -57,8 +59,194 @@ def utcnow() -> datetime:
|
||||
|
||||
|
||||
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)
|
||||
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",
|
||||
metadata,
|
||||
Column("key", String(64), primary_key=True),
|
||||
Column("value", Text),
|
||||
)
|
||||
|
||||
workers = Table(
|
||||
"workers",
|
||||
metadata,
|
||||
Column("name", String(128), primary_key=True),
|
||||
Column("encoders", Text),
|
||||
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||
Column("last_seen", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
||||
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
||||
import json
|
||||
|
||||
payload = json.dumps(encoder_liste)
|
||||
info_payload = json.dumps(info or {})
|
||||
with engine.begin() as conn:
|
||||
vorhanden = conn.execute(
|
||||
workers.select().where(workers.c.name == name)
|
||||
).first()
|
||||
if vorhanden:
|
||||
conn.execute(
|
||||
workers.update().where(workers.c.name == name).values(
|
||||
encoders=payload, info=info_payload, last_seen=utcnow()
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
workers.insert().values(
|
||||
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_settings(key: str = "ui") -> dict:
|
||||
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||
).first()
|
||||
if zeile and zeile[0]:
|
||||
return json.loads(zeile[0])
|
||||
except Exception:
|
||||
pass
|
||||
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 save_settings(werte: dict, key: str = "ui") -> None:
|
||||
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
payload = json.dumps(werte)
|
||||
with engine.begin() as conn:
|
||||
vorhanden = conn.execute(
|
||||
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||
).first()
|
||||
if vorhanden:
|
||||
conn.execute(
|
||||
settings_table.update()
|
||||
.where(settings_table.c.key == key)
|
||||
.values(value=payload)
|
||||
)
|
||||
else:
|
||||
conn.execute(settings_table.insert().values(key=key, value=payload))
|
||||
|
||||
|
||||
def get_job_status(job_id: str) -> str:
|
||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||
from sqlalchemy import select
|
||||
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.status).where(jobs.c.id == job_id)
|
||||
).first()
|
||||
return zeile[0] if zeile else ""
|
||||
|
||||
|
||||
def list_jobs_mit_status(stati) -> list:
|
||||
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
|
||||
|
||||
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
||||
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile — die
|
||||
Erkennung braucht nichts weiter.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
||||
.where(jobs.c.status.in_(list(stati)))
|
||||
).mappings().all()
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||
"""Wie viele Worker gelten laut Herzschlag gerade als online?
|
||||
|
||||
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
|
||||
melden sich weniger Worker als bekannt sind, ist die Auskunft
|
||||
unvollständig — dann wird NICHTS als Leiche gewertet.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
grenze = utcnow() - timedelta(seconds=sekunden)
|
||||
with engine.connect() as conn:
|
||||
anzahl = conn.execute(
|
||||
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
||||
).scalar()
|
||||
return int(anzahl or 0)
|
||||
|
||||
|
||||
def meta_merken(job_id: str, **felder) -> None:
|
||||
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
||||
|
||||
`update_job(meta=...)` würde die Spalte ersetzen — Poster, Jahr, Titel-Wahl
|
||||
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
|
||||
schreiben.
|
||||
|
||||
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
|
||||
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern — im
|
||||
schlimmsten Fall fehlt die Marke und der „Neu"-Knopf fragt nach.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
zeile = get_job(job_id)
|
||||
if not zeile:
|
||||
return
|
||||
try:
|
||||
vorher = json.loads(zeile.get("meta") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
vorher = {}
|
||||
if not isinstance(vorher, dict):
|
||||
vorher = {}
|
||||
vorher.update(felder)
|
||||
update_job(job_id, meta=json.dumps(vorher))
|
||||
except Exception as e:
|
||||
try:
|
||||
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields) -> None:
|
||||
|
||||
@@ -36,6 +36,10 @@ BLKGETSIZE64 = 0x80081272
|
||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
||||
# Alles ab 10 GB ist also sicher eine Blu-ray.
|
||||
BLURAY_MIN_BYTES = 10 * 1024**3
|
||||
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
|
||||
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
|
||||
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
|
||||
UHD_MIN_BYTES = 55 * 1024**3
|
||||
|
||||
|
||||
def _open_nonblock(device_path: str) -> int:
|
||||
@@ -73,10 +77,12 @@ def disc_size_bytes(device_path: str) -> int:
|
||||
|
||||
|
||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
|
||||
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
|
||||
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
|
||||
return "cd"
|
||||
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
|
||||
if size_bytes >= UHD_MIN_BYTES:
|
||||
return "uhd"
|
||||
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
|
||||
return "unknown"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user