Compare commits
164
Commits
fd1feaaee3
...
aktuell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b | ||
|
|
6881a63fb7 | ||
|
|
35370fd552 | ||
|
|
11001a443e | ||
|
|
be3fac53af | ||
|
|
878b24c43b | ||
|
|
ea8a043643 | ||
|
|
95beb91364 | ||
|
|
480b658294 | ||
|
|
16df61ea3a | ||
|
|
3d5f82b786 | ||
|
|
8e65411189 | ||
|
|
082e8b8b55 | ||
|
|
6dfff5c99d | ||
|
|
8eca982e68 | ||
|
|
151376aee3 | ||
|
|
b47d21510d | ||
|
|
a4a045f303 | ||
|
|
66642b8d93 | ||
|
|
a5e64f8e56 | ||
|
|
ce4e7e6b8a | ||
|
|
60c0f93237 | ||
|
|
e1984dc543 | ||
|
|
ca212ff835 | ||
|
|
bfc0cada62 | ||
|
|
e1577a4f47 | ||
|
|
c01ef1081c | ||
|
|
069fc46f44 | ||
|
|
99c05865af | ||
|
|
f4a8d77598 | ||
|
|
288f9eeb8d | ||
|
|
059183c651 | ||
|
|
e5254c23f0 | ||
|
|
9d95c95d79 | ||
|
|
3d7f3b1680 | ||
|
|
b723fee3a2 | ||
|
|
4337253797 | ||
|
|
05ab655116 | ||
|
|
95705c8d88 | ||
|
|
f097a0b59d | ||
|
|
1fbe2cfc55 | ||
|
|
55db9eb13f | ||
|
|
d2645f87e8 | ||
|
|
7558be4854 | ||
|
|
dd1d0b7365 | ||
|
|
edfe0d4313 | ||
|
|
b98dc5eef5 | ||
|
|
20675a98fa | ||
|
|
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 |
+11
-1
@@ -29,11 +29,21 @@ Thumbs.db
|
||||
.dockerignore
|
||||
.docker/
|
||||
|
||||
# Test
|
||||
# Test — ** ist Pflicht, sonst greifen die Muster NUR in der obersten Ebene
|
||||
# des Bau-Kontexts. Ohne ** landeten am 28.08.2026 dreissig test_*.py in beiden
|
||||
# Laufzeit-Images (im laufenden Container nachgezaehlt: find /app -name
|
||||
# "test_*.py" | wc -l -> 30). Schadet nichts, gehoert aber nicht ins Image.
|
||||
tests/
|
||||
**/tests/
|
||||
test_*.py
|
||||
**/test_*.py
|
||||
*_test.py
|
||||
**/*_test.py
|
||||
.pytest_cache/
|
||||
**/.pytest_cache/
|
||||
**/__pycache__/
|
||||
conftest.py
|
||||
**/conftest.py
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
@@ -52,6 +52,17 @@ jobs:
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
echo "== Node/TypeScript erkannt =="
|
||||
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||
if [ "$node_major" -lt 22 ]; then
|
||||
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||
rot=1
|
||||
fi
|
||||
while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
|
||||
@@ -54,3 +54,7 @@ logs/
|
||||
# Data
|
||||
*.db
|
||||
*.sqlite
|
||||
dist/
|
||||
|
||||
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||
design-entwuerfe/
|
||||
|
||||
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
||||
|
||||
### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben
|
||||
|
||||
**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.**
|
||||
Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade
|
||||
ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint
|
||||
dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch
|
||||
ist. An einem Tag vier Mal passiert.
|
||||
|
||||
falsch: "einen Ordner unter „Benutzer" wählen"
|
||||
richtig: 'einen Ordner unter „Benutzer" wählen'
|
||||
|
||||
**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE
|
||||
Anführungszeichen.**
|
||||
|
||||
**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein,
|
||||
halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`,
|
||||
und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert,
|
||||
zuletzt beim Schreiben genau dieses Absatzes.
|
||||
|
||||
**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug
|
||||
schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über
|
||||
`chr(92)` zusammensetzen.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
||||
@@ -55,7 +78,7 @@ 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 (25.07.2026)
|
||||
## Aktueller Stand (26.07.2026)
|
||||
|
||||
- ✅ **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,
|
||||
@@ -73,6 +96,25 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
- ✅ **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
|
||||
@@ -87,3 +129,67 @@ 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.
|
||||
|
||||
+1493
File diff suppressed because it is too large
Load Diff
+1131
File diff suppressed because it is too large
Load Diff
+66
-1
@@ -36,7 +36,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
||||
| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
|
||||
| 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 | | |
|
||||
@@ -183,6 +183,22 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
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**
|
||||
@@ -204,3 +220,52 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
||||
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||
EINDEUTIGER Zuordnung wird umbenannt).
|
||||
- **28.08.2026 — DISC-SCHLÜSSEL: die Grenze vom 25.07. ist verschoben
|
||||
(Commander-Entscheid).** Der Eintrag vom 25.07.2026 oben sagt: *„Rippy
|
||||
liefert und verteilt KEINE Disc-Schlüssel und lädt auch keine herunter."*
|
||||
**Das gilt ab jetzt nicht mehr unverändert.** Auf ausdrückliche Entscheidung
|
||||
des Commanders bekommt Rippy v2 einen automatischen Abruf — mit den
|
||||
bisherigen Wegen als Rückfallebene. Umgesetzt als Kette in DREI Stufen, die
|
||||
der Reihe nach abgearbeitet wird und anhält, sobald eine trägt:
|
||||
|
||||
1. **Eigener Bestand** (Schlüsselspeicher der Installation) — immer zuerst.
|
||||
Gefüllt vom Windows-Knoten, der die Schlüssel über die eigene
|
||||
MakeMKV-Lizenz selbst abruft (unter Linux tut `makemkvcon` das nie,
|
||||
Messung 25.07.2026), und von jedem Import.
|
||||
2. **Automatischer Abruf** von der konfigurierten Quelle — wenn Stufe 1
|
||||
die Disc nicht kennt.
|
||||
3. **Import von Hand** (`_private_data.tar`, `KEYDB.cfg` über das UI) —
|
||||
unverändert aus v1.
|
||||
|
||||
Fünf Regeln gehören zum Entscheid dazu: (a) die Bezugsadresse steht in der
|
||||
KONFIGURATION und ist LEER vorbelegt — ohne Eintrag ist Stufe 2
|
||||
übersprungen; eine vorbelegte Adresse, die irgendwann tot ist, wäre genau
|
||||
die Falle aus `.env.example` („lässt die Konfiguration gesund aussehen und
|
||||
den Bau später scheitern"). (b) Ein funktionierender Bestand wird NIE still
|
||||
überschrieben — neue Datei daneben, prüfen, dann tauschen. (c) Ein
|
||||
Fehlschlag ist LAUT (kein `except: pass`, Meldung im Log und im UI).
|
||||
(d) Das UI zeigt Herkunft und Alter jedes Eintrags. (e) **Rippy bringt
|
||||
selbst nichts mit** — weder Installer noch Docker-Image enthalten Schlüssel
|
||||
oder eine vorbelegte Bezugsadresse; was abgerufen wird, trägt der Betreiber
|
||||
der Installation ein. Ausführlich in `KONZEPT-V2.md` § 7.5 und § 10.
|
||||
- **28.08.2026 — SPEICHERZIELE: der Host mountet, Rippy prüft
|
||||
(Commander-Entscheid).** Das Muss-Feature „NFS/Bind-Mount für Medien-Store"
|
||||
(§ 6) bleibt; was wegfällt, ist das Mounten DURCH Rippy. Begründung ist der
|
||||
Befund vom 26.07.2026: Die CIFS-Verbindung lebt in der Netz-Namespace des
|
||||
api-Containers und stirbt mit ihm — dagegen läuft heute eine Mount-Wache.
|
||||
In v2 hängt der Host ein (fstab, `.mount`-Unit oder Compose-Volume-Treiber),
|
||||
und Rippy erzeugt dafür die fertige, kopierbare Zeile. Die Eingabemaske im
|
||||
UI bleibt; der Knopf „Verbinden" wird zu „Zeile kopieren". Die gesamte
|
||||
PRÜF-Logik aus `mounts.py` bleibt erhalten (Erreichbarkeit mit Zeitgrenze
|
||||
im Kindprozess, SMB-Klartextfehler, Pfad-Map-Vorschlag). Folge:
|
||||
`CAP_SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`,
|
||||
`propagation: rshared` und die Mount-Wache fallen ersatzlos weg.
|
||||
- **28.08.2026 — RIPPY v2: drei Betriebsmodi statt eines
|
||||
(Commander-Auftrag).** Die Multi-Container-Architektur aus § 6 bleibt als
|
||||
EINER von drei Modi bestehen. Dazu kommen eine native Windows-Installation
|
||||
ohne Docker und eine Headless-Linux-Anwendung — beide mit demselben
|
||||
Webinterface. Technisch tragen alle drei denselben Kern; ein Modus ist nur
|
||||
die Auswahl der Treiber hinter vier Ports (Store, Queue, Bus, Drives).
|
||||
Damit sind Postgres und Redis für Einzelinstallationen keine Pflicht mehr
|
||||
(SQLite + lokale Queue), bleiben im verteilten Modus aber unverändert.
|
||||
Vollständige Spezifikation: **`KONZEPT-V2.md`**, Etappen in `ROADMAP.md`.
|
||||
|
||||
@@ -11,6 +11,12 @@ in Docker, komplett lokal — keine Cloud, keine Telemetrie.
|
||||
|
||||
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 <repo-url> rippy && cd rippy
|
||||
sudo ./install.sh
|
||||
|
||||
+140
@@ -570,8 +570,148 @@ heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
|
||||
|
||||
---
|
||||
|
||||
## Rippy v2 — Etappen V2-0 bis V2-7 (Plan vom 28.08.2026)
|
||||
|
||||
**Vollständige Spezifikation: `KONZEPT-V2.md`.** Hier stehen nur die Etappen
|
||||
und ihre Abnahmekriterien.
|
||||
|
||||
**Ziel:** drei Betriebsmodi statt eines — Docker (wie heute, aufgeräumt), eine
|
||||
native Windows-App ohne Docker, eine Headless-Linux-Anwendung. Alle drei mit
|
||||
demselben Webinterface, alle drei aus EINEM Kern.
|
||||
|
||||
**Grundregel für den ganzen Weg:** Jede Etappe endet mit grüner Ampel und einem
|
||||
lauffähigen System. v1 läuft bis V2-5 produktiv weiter — auf der VM liegen echte
|
||||
Medien.
|
||||
|
||||
### V2-0: Monorepo — ein Paket statt Zwillingen
|
||||
- [ ] `src/rippy/` als gemeinsames Paket anlegen (api UND worker importieren daraus)
|
||||
- [ ] Die byte-identischen Zwillinge zusammenführen: `detection.py`,
|
||||
`makemkv_daten.py`, `notify.py` — je zweimal im Repo
|
||||
- [ ] Tests wandern mit; `test_zwillinge_sind_byteweise_identisch` wird
|
||||
gegenstandslos und weicht einem Test, der die EINE Quelle prüft
|
||||
- [ ] Beide Dockerfiles kopieren `src/rippy`, `PYTHONPATH` gesetzt
|
||||
- **Verhalten unverändert.** Kein Funktionsgewinn, reine Struktur.
|
||||
- **Fertig, wenn:** Ampel grün, `docker compose up` verhält sich wie vorher,
|
||||
kein Modul mehr doppelt im Repo.
|
||||
|
||||
### V2-1: Ports einziehen
|
||||
- [ ] `Store`, `Queue`, `Bus`, `Drives` als Python-`Protocol`
|
||||
- [ ] v1-Verhalten läuft über die Treiber Postgres / Celery / Redis / Linux
|
||||
- [ ] Kein Funktionsgewinn — reine Verdrahtung
|
||||
- **Fertig, wenn:** Ampel grün und ein Rip auf der VM durchläuft.
|
||||
|
||||
### V2-2: Standalone (SQLite + lokale Queue)
|
||||
- [ ] SQLite-Treiber mit WAL, `busy_timeout`, ein Schreiber-Kontext
|
||||
- [ ] Alembic statt handgeschriebener `ALTER TABLE … IF NOT EXISTS`
|
||||
(das ist Postgres-only und bricht auf SQLite)
|
||||
- [ ] LocalQueue: Auftrags-Tabelle + Lease + Prozesspool, kein Broker
|
||||
- [ ] Lease ersetzt `zombies.py` — abgelaufene Lease = Auftrag ist frei
|
||||
- [ ] `rippyd --profil standalone`
|
||||
- **Fertig, wenn:** ein DVD-Rip komplett ohne Postgres, Redis und Docker läuft.
|
||||
|
||||
### V2-3: Echtzeit — Polling raus
|
||||
- [ ] Bus-Treiber (asyncio in-process / Redis Pub/Sub)
|
||||
- [ ] `GET /api/v2/events` als SSE-Strom, `snapshot` beim Verbinden,
|
||||
lückenlose `seq` für Wiederaufnahme
|
||||
- [ ] UI auf einen `useEventStream`-Haken; die neun `setInterval` fliegen raus
|
||||
- [ ] `test_grenze_deckt_die_eigene_last_ab` auf die neuen Zahlen ziehen
|
||||
- **Fertig, wenn:** Grundlast ≈ 0/min gemessen (heute ≈ 133/min bei einem Tab
|
||||
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
||||
|
||||
### V2-4: Windows nativ
|
||||
- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
|
||||
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
|
||||
MMC `GET CONFIGURATION` für den Disc-Typ)
|
||||
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
|
||||
vorerst über den Poll aus `drives/detection.py`
|
||||
- [x] Dienst + Fenster als GETRENNTE Prozesse; **WebView2-Fenster**
|
||||
(`fenster.py`, Entscheid 4 in `KONZEPT-V2.md` § 10)
|
||||
- [x] Eintrag in „Programme und Features" und im Taskmanager als `Rippy.exe`
|
||||
(ausdrücklicher Wunsch des Commanders)
|
||||
- [x] Desktop-Symbol und Startmenü-Eintrag, im Setup abschaltbar
|
||||
- [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`,
|
||||
`tools/beschaffen.py`) — Rippy findet, holt und aktualisiert
|
||||
HandBrake/MakeMKV selbst
|
||||
- [x] **Das Setup richtet die Werkzeuge wirklich ein** (`tools/einrichten.py`,
|
||||
Entscheid 5) — HandBrakeCLI liegt im Paket (GPL-2), MakeMKV wird beim
|
||||
Einrichten vom Hersteller geholt. Ein Setup, dem ein Pflichtwerkzeug
|
||||
fehlt, meldet das im Klartext statt Erfolg.
|
||||
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
|
||||
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
|
||||
ein leeres Blatt
|
||||
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
|
||||
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
|
||||
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
|
||||
Doppelklick stillschweigend und öffnete das Fenster.
|
||||
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
|
||||
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
|
||||
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
|
||||
behält alles.
|
||||
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
|
||||
— **offen**
|
||||
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
||||
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
|
||||
**Noch offen:** ein echter Rip auf Windows ist ungeprüft — das Laufwerk
|
||||
hängt an der VM.
|
||||
|
||||
> **Abweichung vom Plan, bewusst und mit Folgen.** Statt Nuitka `--standalone`
|
||||
> (one-dir) + Inno Setup wurde **PyInstaller onefile** genommen: ein Programm,
|
||||
> drei Betriebsarten (`RippySetup.exe`, `--dienst`, `--deinstallieren`), kein
|
||||
> zweites Werkzeug in der Kette.
|
||||
>
|
||||
> Der Preis stand oben schon im Plan — *„ein Onefile-Paket entpackt sich bei
|
||||
> jedem Start neu nach `%TEMP%`"* — und er ist am 28.08.2026 fällig geworden:
|
||||
> Ein Dienst lief eine Stunde, `/api/health` gab 200, `/` gab **404**. Vom
|
||||
> Entpack-Verzeichnis des laufenden Prozesses waren 31 statt 44 Einträge übrig,
|
||||
> `ui` und `api` fehlten. Die API meldete sich gesund, während die Oberfläche
|
||||
> weg war.
|
||||
>
|
||||
> **Gegenmaßnahme:** Der Installer legt die Oberfläche als Kopie NEBEN das
|
||||
> Programm (`windows_app.ui_auspacken`), und `daemon._ui_pfad()` nimmt diese
|
||||
> Kopie vor dem Entpack-Verzeichnis. Zwei Tests halten das fest. Sollte sich
|
||||
> derselbe Ausfall an den API-Modulen zeigen, ist one-dir die richtige Antwort
|
||||
> — dann ist diese Abweichung zurückzunehmen.
|
||||
|
||||
### V2-5: Docker neu
|
||||
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
||||
- [ ] GPU-Durchreichung: `/dev/dri` für QSV/VAAPI, `runtime: nvidia` für NVENC
|
||||
- [ ] **Host-Mounts statt Container-Mounts** (Entscheid 28.08.2026)
|
||||
- [ ] Multi-Arch; ARM64 ist Encode-/API-Knoten (MakeMKV hat kein ARM64-Binary)
|
||||
- **Fertig, wenn:** All-in-One und verteilt laufen und `SYS_ADMIN`,
|
||||
`DAC_READ_SEARCH`, `apparmor:unconfined` weg sind.
|
||||
|
||||
### V2-6: CLI & Pakete
|
||||
- [ ] `rippy status / drives / scan / rip / queue / logs --follow / doctor`
|
||||
- [ ] `rippy doctor` = die Prüfphase aus `install.sh` als Befehl (erst alles
|
||||
prüfen, dann berichten, nichts ändern)
|
||||
- [ ] systemd-Unit mit `SupplementaryGroups=cdrom` und `DeviceAllow` für
|
||||
block-sr UND char-sg (ohne sg findet MakeMKV kein Laufwerk)
|
||||
- [ ] udev-Regel für echte Disc-Ereignisse; ioctl-Poll bleibt Rückfallebene
|
||||
- [ ] `.deb` / AppImage
|
||||
- **Fertig, wenn:** ein Headless-Server ohne Browser bedienbar ist.
|
||||
|
||||
### V2-7: Neue Features
|
||||
- [ ] Multi-Drive Parallel-Ripping (Rip parallel, **Schlüssel-Phase
|
||||
serialisiert** — vorher an zwei Laufwerken MESSEN, nicht annehmen)
|
||||
- [ ] Zero-Click gegen Interaktiv, je Laufwerk und Disc-Typ
|
||||
- [ ] Auto-Presets nach gemessener Hardware (Vorschlag, kein Zwang)
|
||||
- [ ] Ton-/Untertitel-Regelwerk (Originalton, Wunschsprachen, erzwungene
|
||||
Untertitel behalten, Kommentarspuren verwerfen, HD-Ton durchreichen)
|
||||
- [ ] **Schlüsselkette in drei Stufen** (Entscheid 28.08.2026, `KONZEPT.md` § 10)
|
||||
- [ ] Anime über AniList zusätzlich zu Jikan
|
||||
- [ ] Medienserver-Refresh als Auftrag MIT Wiederholung (heute still scheiternd)
|
||||
- [ ] FFmpeg-Direktpfad für reines Remuxen
|
||||
- **Fertig, wenn:** je Feature ein Nachweis vorliegt.
|
||||
|
||||
---
|
||||
|
||||
## Ideen-Katalog (Rest) — bewusst offen
|
||||
|
||||
> **Stand 28.08.2026:** Punkt 3 (Kodi-Refresh) und Punkt 4 (Windows-Worker als
|
||||
> Dienst) sind in den v2-Plan aufgegangen — Punkt 4 ist V2-4, Punkt 3 steckt in
|
||||
> V2-7 („Medienserver-Refresh"). Punkt 2 (AI-Box als Transcode-Worker) wird von
|
||||
> V2-5 abgedeckt, sobald der Host-Mount-Weg steht.
|
||||
|
||||
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
|
||||
|
||||
+2793
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||
|
||||
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||
Konzept hängt:
|
||||
|
||||
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||
|
||||
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||
|
||||
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||
|
||||
---
|
||||
|
||||
## Nachstellen
|
||||
|
||||
```
|
||||
npm install koffi
|
||||
node laufwerk.js
|
||||
```
|
||||
|
||||
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||
|
||||
---
|
||||
|
||||
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||
|
||||
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||
|
||||
```
|
||||
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||
| Rev: 1.03 | Serial: 0025114C0149
|
||||
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||
```
|
||||
|
||||
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||
|
||||
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||
ein antwortendes Laufwerk für tot.
|
||||
|
||||
---
|
||||
|
||||
## `leine.js` — die Prozess-Leine (Job Object)
|
||||
|
||||
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||
|
||||
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||
Drüber-Installieren passiert.
|
||||
|
||||
```
|
||||
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||
4. Kindprozess gestartet, PID 16040
|
||||
|
||||
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||
danach: Eltern lebt: False Kind lebt: False
|
||||
```
|
||||
|
||||
Nachstellen (PowerShell):
|
||||
|
||||
```powershell
|
||||
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||
Start-Sleep 3
|
||||
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||
taskkill /F /PID $p.Id
|
||||
Start-Sleep 2
|
||||
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||
|
||||
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||
überhaupt vorhanden?
|
||||
|
||||
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||
|
||||
```
|
||||
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||
Kern beendet mit 0
|
||||
```
|
||||
|
||||
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||
Messung zuvor ebenfalls grün.)
|
||||
|
||||
Nachstellen:
|
||||
|
||||
```
|
||||
npm install electron@44 --no-save
|
||||
node node_modules/electron/install.js
|
||||
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||
```
|
||||
|
||||
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||
|
||||
---
|
||||
|
||||
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||
|
||||
```
|
||||
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||
```
|
||||
|
||||
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||
Tages jemand an der falschen Stelle.
|
||||
@@ -0,0 +1,109 @@
|
||||
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||
const koffi = require('koffi');
|
||||
|
||||
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||
const ctl = (typ, fn, methode, zugriff) =>
|
||||
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||
|
||||
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
|
||||
|
||||
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||
const DRIVE_CDROM = 5;
|
||||
|
||||
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||
const maske = GetLogicalDrives();
|
||||
const optische = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (!(maske & (1 << i))) continue;
|
||||
const buchstabe = String.fromCharCode(65 + i);
|
||||
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||
}
|
||||
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const lw = optische[0];
|
||||
|
||||
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||
function oeffnen(zugriff) {
|
||||
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||
const adr = koffi.address(h);
|
||||
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||
return { h, fehler: 0 };
|
||||
}
|
||||
|
||||
const ohneMedium = oeffnen(0);
|
||||
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||
const mitLesen = oeffnen(GENERIC_READ);
|
||||
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||
|
||||
const h = ohneMedium.h;
|
||||
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const rueck = Buffer.alloc(4);
|
||||
|
||||
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||
const anfrage = Buffer.alloc(12);
|
||||
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||
const antwort = Buffer.alloc(1024);
|
||||
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||
const text = (off) => {
|
||||
const start = antwort.readUInt32LE(off);
|
||||
if (!start || start >= antwort.length) return '';
|
||||
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||
return antwort.toString('latin1', start, ende).trim();
|
||||
};
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||
} else {
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||
|
||||
// ── 5. Audio- oder Datenspur?
|
||||
const typBuf = Buffer.alloc(1);
|
||||
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||
const t = typBuf[0];
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||
} else {
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||
const laenge = Buffer.alloc(8);
|
||||
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||
const bytes = laenge.readBigUInt64LE(0);
|
||||
const gb = Number(bytes) / 1e9;
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||
} else {
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
CloseHandle(h);
|
||||
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||
@@ -0,0 +1,50 @@
|
||||
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||
const koffi = require('koffi');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
|
||||
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||
const JobObjectExtendedLimitInformation = 9;
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
|
||||
const job = CreateJobObjectW(null, null);
|
||||
if (koffi.address(job) === 0n) {
|
||||
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||
const info = Buffer.alloc(144);
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||
|
||||
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||
console.log('ELTERN-PID=' + process.pid);
|
||||
console.log('KIND-PID=' + kind.pid);
|
||||
|
||||
// Offen halten, bis uns jemand hart abschiesst.
|
||||
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||
@@ -0,0 +1,12 @@
|
||||
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||
try {
|
||||
const s = require('node:sqlite');
|
||||
const db = new s.DatabaseSync(':memory:');
|
||||
db.exec('CREATE TABLE t (a TEXT)');
|
||||
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||
} catch (e) {
|
||||
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||
//
|
||||
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||
const { app, utilityProcess } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||
kind.on('message', (antwort) => {
|
||||
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||
app.exit(antwort.ok ? 0 : 1);
|
||||
});
|
||||
kind.on('exit', (code) => {
|
||||
console.log('Kern beendet mit ' + code);
|
||||
if (code !== 0) app.exit(1);
|
||||
});
|
||||
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
+22
@@ -0,0 +1,22 @@
|
||||
"""Pytest-Setup für das ganze Repo: das gemeinsame Paket `rippy` findbar machen.
|
||||
|
||||
Die Ampel ruft `pytest -q` in der Repo-Wurzel auf (.gitea/workflows/ci.yml).
|
||||
Ohne diesen Eintrag scheitert JEDER Import von `rippy.*` — das Paket liegt
|
||||
unter `src/`, und `src/` steht in keinem Standardpfad.
|
||||
|
||||
Warum `src/` und nicht `rippy/` direkt in der Wurzel: Sonst würde `pytest`
|
||||
beim Einsammeln in dasselbe Verzeichnis schauen, in dem auch `docker/` liegt,
|
||||
und ein zufällig gleichnamiges Modul könnte gewinnen. Ein eigenes Quellen-
|
||||
Verzeichnis macht die Grenze eindeutig.
|
||||
|
||||
Die beiden conftest.py unter docker/api und docker/worker bleiben bestehen —
|
||||
sie machen die dortigen FLACHEN Modul-Importe möglich (`import ripping`,
|
||||
`import db`). Beide Mechanismen greifen nebeneinander.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_QUELLEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
||||
if _QUELLEN not in sys.path:
|
||||
sys.path.insert(0, _QUELLEN)
|
||||
@@ -34,7 +34,34 @@ set -euo pipefail
|
||||
REPO_URL="$1"
|
||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||
CLONE="${3:-$HOME/notfall-rippy}"
|
||||
DIENST="$4"
|
||||
# ⚠️ ${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
|
||||
|
||||
if [ ! -d "$CLONE/.git" ]; then
|
||||
git clone --branch main "$REPO_URL" "$CLONE"
|
||||
@@ -42,7 +69,44 @@ fi
|
||||
cd "$CLONE"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||
# ⚠️ 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
|
||||
|
||||
# Geraete dieses Hosts ermitteln, BEVOR gestartet wird.
|
||||
# Ohne diesen Schritt haengt der Start an einem `devices:`-Eintrag, den es
|
||||
# vielleicht nicht mehr gibt — am 28.08.2026 legte genau das die ganze
|
||||
# Installation stillt, weil das USB-Laufwerk abgezogen war (Herleitung im Kopf
|
||||
# von docker-compose.yml). Das Skript schreibt die Override immer neu, auch
|
||||
# wenn kein Laufwerk da ist.
|
||||
if [ -x ./deploy/geraete-override.sh ]; then
|
||||
./deploy/geraete-override.sh .
|
||||
else
|
||||
echo "WARNUNG: deploy/geraete-override.sh fehlt — starte ohne Geraete-Erkennung."
|
||||
fi
|
||||
|
||||
docker compose -p rippy up -d --build $DIENST
|
||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Erzeugt docker-compose.override.yml mit den optischen Laufwerken DIESES Hosts.
|
||||
#
|
||||
# ## Warum es das gibt (Vorfall 28.08.2026)
|
||||
#
|
||||
# In docker-compose.yml stand:
|
||||
#
|
||||
# devices:
|
||||
# - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
#
|
||||
# Das ist eine STARTBEDINGUNG. Fehlt das Laufwerk, startet der Container nicht:
|
||||
#
|
||||
# Error response from daemon: error gathering device information while
|
||||
# adding custom device "/dev/sr0": no such file or directory
|
||||
#
|
||||
# Genau das ist passiert, als das USB-Laufwerk von der VM abgezogen wurde: Ein
|
||||
# ganz normaler Deploy legte api, worker UND ui still — obwohl keiner der drei
|
||||
# ein Laufwerk zum Starten braucht. Rippy war unten, und die Meldung stand
|
||||
# mitten im Build-Rauschen.
|
||||
#
|
||||
# Der Widerspruch war schon vorher da: `install.sh` sagt bei fehlendem Laufwerk
|
||||
# ausdrücklich „die Installation läuft trotzdem durch; diese Maschine dient dann
|
||||
# als reine KOMPRIMIER-Maschine" — die Compose-Datei sah das anders. Eine
|
||||
# Maschine ohne Laufwerk ist ein vorgesehener Betriebsfall (der Windows-Worker
|
||||
# und der GPU-Encoder sind genau das).
|
||||
#
|
||||
# Seither steht in docker-compose.yml KEIN devices-Block mehr. Was dieser Host
|
||||
# wirklich hat, landet in docker-compose.override.yml — die zieht Compose von
|
||||
# allein dazu (`docker compose up` braucht keinen extra Schalter), und sie ist
|
||||
# gitignored, weil die Geräteknoten je Rechner anders heißen.
|
||||
#
|
||||
# Aufruf: ./deploy/geraete-override.sh [zielverzeichnis]
|
||||
# (ohne Argument: das aktuelle Verzeichnis)
|
||||
#
|
||||
set -u
|
||||
|
||||
ZIEL="${1:-.}"
|
||||
DATEI="$ZIEL/docker-compose.override.yml"
|
||||
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht deshalb
|
||||
# ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer dazugehört,
|
||||
# ist je Host anders — sie wird über die SCSI-Adresse ermittelt statt geraten.
|
||||
# Beide Knoten zeigen in /sys auf dasselbe Geräteverzeichnis (z. B. 3:0:0:0).
|
||||
# (Dieselbe Herleitung wie in install.sh, Prüfung 2/5.)
|
||||
SR=""
|
||||
SG=""
|
||||
LAUFWERK=""
|
||||
for srpfad in /sys/block/sr*; do
|
||||
[ -e "$srpfad" ] || continue
|
||||
srname=$(basename "$srpfad")
|
||||
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
|
||||
[ -n "$adresse" ] || continue
|
||||
for sgpfad in /sys/class/scsi_generic/sg*; do
|
||||
[ -e "$sgpfad" ] || continue
|
||||
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
|
||||
SR="/dev/$srname"
|
||||
SG="/dev/$(basename "$sgpfad")"
|
||||
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
kopf() {
|
||||
cat <<KOPF
|
||||
# ERZEUGT von deploy/geraete-override.sh — nicht von Hand ändern.
|
||||
# Diese Datei ist gitignored: Die Geräteknoten heißen auf jedem Rechner anders.
|
||||
# Neu erzeugen nach jedem An- oder Abstecken eines Laufwerks:
|
||||
# ./deploy/geraete-override.sh && docker compose up -d
|
||||
KOPF
|
||||
}
|
||||
|
||||
if [ -z "$SR" ]; then
|
||||
# KEIN Laufwerk. Wichtig: trotzdem eine gültige Datei schreiben und die alte
|
||||
# damit überschreiben. Bliebe eine alte Override mit /dev/sr0 liegen, wäre der
|
||||
# Fehler beim nächsten Start genau derselbe — nur schwerer zu finden, weil die
|
||||
# Datei gitignored ist und in keinem Diff auftaucht.
|
||||
{
|
||||
kopf
|
||||
echo "#"
|
||||
echo "# Auf diesem Host wurde KEIN optisches Laufwerk gefunden."
|
||||
echo "# Rippy läuft damit als reine Komprimier-Maschine — das ist ein"
|
||||
echo "# vorgesehener Betriebsfall, kein Fehler. Zum Rippen das Laufwerk"
|
||||
echo "# anstecken (in einer VM: USB-Passthrough) und dieses Skript erneut"
|
||||
echo "# aufrufen."
|
||||
echo "services: {}"
|
||||
} > "$DATEI"
|
||||
echo "Kein optisches Laufwerk gefunden — $DATEI ohne Geräte geschrieben."
|
||||
echo "Rippy startet und kann komprimieren; Rippen geht erst mit Laufwerk."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
kopf
|
||||
echo "#"
|
||||
echo "# Gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
|
||||
echo "# $SR + $SG (über die SCSI-Adresse abgeglichen, nicht geraten)"
|
||||
cat <<YAML
|
||||
services:
|
||||
api:
|
||||
devices:
|
||||
- $SR:/dev/sr0
|
||||
worker:
|
||||
devices:
|
||||
- $SR:/dev/sr0
|
||||
- $SG:/dev/sg1
|
||||
YAML
|
||||
} > "$DATEI"
|
||||
|
||||
echo "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
|
||||
echo " $SR + $SG -> $DATEI"
|
||||
@@ -1,17 +1,36 @@
|
||||
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz.
|
||||
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
|
||||
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
|
||||
# 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. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
|
||||
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
|
||||
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
|
||||
# 3. RIPPY_HOST unten setzen und starten:
|
||||
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
|
||||
# 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 → Verarbeitung auf. Er bedient NUR die transcode-Queue;
|
||||
# unter Einstellungen → Worker auf. Er bedient NUR die transcode-Queue;
|
||||
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||
|
||||
services:
|
||||
@@ -30,10 +49,15 @@ services:
|
||||
- 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:
|
||||
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
||||
- /mnt/rippy/media:/app/media
|
||||
- /mnt/rippy/temp:/app/temp
|
||||
# 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
|
||||
|
||||
Binary file not shown.
@@ -1,68 +0,0 @@
|
||||
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
||||
#
|
||||
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberfläche)
|
||||
# ändert. NICHT bei MakeMKV-/HandBrake-Updates - die .exe holt HandBrake zur
|
||||
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
|
||||
# keine Versionen ein.
|
||||
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
|
||||
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
|
||||
|
||||
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
|
||||
$bmp = New-Object System.Drawing.Bitmap 256, 256
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = "AntiAlias"
|
||||
$g.Clear([System.Drawing.Color]::Transparent)
|
||||
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
|
||||
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
|
||||
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$png = $ms.ToArray()
|
||||
|
||||
$icoStream = New-Object System.IO.MemoryStream
|
||||
$bw = New-Object System.IO.BinaryWriter $icoStream
|
||||
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
|
||||
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
|
||||
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
|
||||
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
|
||||
$bw.Write($png); $bw.Flush()
|
||||
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
|
||||
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
|
||||
|
||||
# 2. ps2exe sicherstellen - TLS 1.2 ist der Fix für den NuGet-Bootstrap-Fehler
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
if (-not (Get-Module -ListAvailable ps2exe)) {
|
||||
Write-Host "Bootstrappe NuGet + PSGallery ..."
|
||||
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
|
||||
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||
Write-Host "Installiere ps2exe ..."
|
||||
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
||||
}
|
||||
Import-Module ps2exe
|
||||
|
||||
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
|
||||
#
|
||||
# -requireAdmin (seit 25.07.2026): Der Worker wird standardmäßig nach
|
||||
# "C:\Program Files\Rippy Worker" installiert, und dorthin darf nur ein
|
||||
# Administrator schreiben. Mit dem Schalter tragt die .exe ein Manifest, das
|
||||
# Windows beim Doppelklick EINMAL per UAC fragen lässt - danach stimmen die
|
||||
# Rechte für alles Weitere. Ohne ihn liefe der Installer unerhöht und
|
||||
# scheiterte beim Anlegen des Ordners; wer das Ziel ins eigene Profil legt,
|
||||
# braucht die Erhöhung nicht, wird aber trotzdem gefragt (normal für einen
|
||||
# Installer und besser als ein Fehlschlag mitten im Lauf).
|
||||
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
||||
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA -requireAdmin `
|
||||
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.1.0"
|
||||
|
||||
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
|
||||
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
|
||||
} else {
|
||||
Write-Host "FEHLER: keine .exe erzeugt"
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
||||
# Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
||||
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
||||
#
|
||||
# WICHTIG - KODIERUNG: Diese Datei MUSS als UTF-8 MIT BOM gespeichert werden.
|
||||
# PowerShell 5.1 liest .ps1 ohne BOM als ANSI; Umlaute werden dann zu Kauderwelsch
|
||||
# UND das Skript wirft einen Parser-Fehler, weil in "ö" ein Anführungszeichen
|
||||
# steckt. Mit BOM liest 5.1 korrekt UTF-8 - am 25.07.2026 beidseitig gemessen:
|
||||
# ohne BOM: "Größe" + Unerwartetes Token
|
||||
# mit BOM: "Größe" + laeuft
|
||||
# Nach jeder Änderung pruefen: powershell.exe -Command "Parser::ParseFile(...)"
|
||||
|
||||
param(
|
||||
[string]$RippyHost = "",
|
||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
# Standard-Zielverzeichnis: wie jedes andere Programm unter "Programme"
|
||||
# (Commander-Wunsch 25.07.2026 - vorher lag der Worker im Benutzerprofil, was
|
||||
# für ein Programm unüblich ist). GetFolderPath liefert den ECHTEN Pfad;
|
||||
# deutsche Windows-Versionen zeigen im Explorer "Programme" an, der Pfad heißt
|
||||
# trotzdem "C:\Program Files".
|
||||
#
|
||||
# Schreiben dorthin braucht Administratorrechte - deshalb ist die .exe mit
|
||||
# -requireAdmin gebaut (build-exe.ps1) und fragt beim Start einmal per UAC.
|
||||
# Ohne das wäre hier der nächste "Zugriff verweigert" programmiert.
|
||||
$StandardZiel = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker"
|
||||
$InstallDir = $StandardZiel
|
||||
|
||||
# --- Farben: dieselben Töne wie das Rippy-Web-UI ---------------------------
|
||||
# Übernommen aus docker/ui/src/lib/design.ts bzw. den Tailwind-Klassen des UI,
|
||||
# damit der Installer nicht wie ein Fremdkörper wirkt.
|
||||
$cBg = [System.Drawing.Color]::FromArgb(15, 23, 42) # slate-900, Fensterfläche
|
||||
$cPanel = [System.Drawing.Color]::FromArgb(2, 6, 23) # slate-950, Log + Eingaben
|
||||
$cFeld = [System.Drawing.Color]::FromArgb(30, 41, 59) # slate-800, Eingabefelder
|
||||
$cText = [System.Drawing.Color]::FromArgb(226, 232, 240) # slate-200
|
||||
$cGedaempft= [System.Drawing.Color]::FromArgb(148, 163, 184) # slate-400, Beschriftungen
|
||||
$cIndigo = [System.Drawing.Color]::FromArgb(99, 102, 241)
|
||||
$cAmber = [System.Drawing.Color]::FromArgb(245, 158, 11)
|
||||
$cEmerald = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
||||
$cPurple = [System.Drawing.Color]::FromArgb(126, 34, 206)
|
||||
|
||||
# --- Fenster ---------------------------------------------------------------
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Rippy Encoding-Worker - Installation"
|
||||
$form.ClientSize = New-Object System.Drawing.Size(560, 560)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.MaximizeBox = $false
|
||||
$form.BackColor = $cBg
|
||||
$form.ForeColor = $cText
|
||||
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
|
||||
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weißes Loch)
|
||||
# - kein externes .ico nötig. Erscheint in Titelleiste und Taskleiste.
|
||||
try {
|
||||
$ibmp = New-Object System.Drawing.Bitmap 32, 32
|
||||
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
|
||||
$ig.SmoothingMode = "AntiAlias"
|
||||
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
|
||||
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
|
||||
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
|
||||
} catch {}
|
||||
|
||||
# --- Kopfbereich mit Farbverlauf --------------------------------------------
|
||||
# Das Rippy-UI hat oben einen Verlauf amber -> indigo -> purple (siehe
|
||||
# FirstRunWizard.tsx). WinForms kann das nicht von sich aus, also wird der
|
||||
# Verlauf im Paint-Ereignis gezeichnet - inklusive Disc-Symbol.
|
||||
$kopf = New-Object System.Windows.Forms.Panel
|
||||
$kopf.Dock = "Top"
|
||||
$kopf.Height = 104
|
||||
$form.Controls.Add($kopf)
|
||||
$kopf.Add_Paint({
|
||||
param($absender, $e)
|
||||
$flaeche = New-Object System.Drawing.Rectangle 0, 0, $absender.Width, $absender.Height
|
||||
$pinsel = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||
$flaeche, $cAmber, $cPurple, [System.Drawing.Drawing2D.LinearGradientMode]::Horizontal)
|
||||
# Dreifarbig wie im UI: der Indigo-Ton in der Mitte
|
||||
$mischung = New-Object System.Drawing.Drawing2D.ColorBlend 3
|
||||
$mischung.Colors = @($cAmber, $cIndigo, $cPurple)
|
||||
$mischung.Positions = @(0.0, 0.45, 1.0)
|
||||
$pinsel.InterpolationColors = $mischung
|
||||
$e.Graphics.FillRectangle($pinsel, $flaeche)
|
||||
$pinsel.Dispose()
|
||||
|
||||
# Disc-Symbol: Ring aussen, weisses Loch in der Mitte
|
||||
$e.Graphics.SmoothingMode = "AntiAlias"
|
||||
$weiss = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(235, 255, 255, 255))
|
||||
$e.Graphics.FillEllipse($weiss, 24, 28, 48, 48)
|
||||
$e.Graphics.FillEllipse((New-Object System.Drawing.SolidBrush $cIndigo), 36, 40, 24, 24)
|
||||
$e.Graphics.FillEllipse($weiss, 45, 49, 6, 6)
|
||||
$weiss.Dispose()
|
||||
|
||||
# Texte direkt zeichnen - so bleibt der Verlauf ohne Kästchen sichtbar
|
||||
$fTitel = New-Object System.Drawing.Font("Segoe UI", 15, [System.Drawing.FontStyle]::Bold)
|
||||
$fUnter = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
$e.Graphics.DrawString("Rippy Encoding-Worker", $fTitel,
|
||||
[System.Drawing.Brushes]::White, 88, 26)
|
||||
$e.Graphics.DrawString(
|
||||
"Diese Maschine übernimmt die Video-Kompression für Rippy." + [char]10 +
|
||||
"Gerippt wird weiter auf der Rippy-Hauptmaschine.", $fUnter,
|
||||
(New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(225, 255, 255, 255))), 90, 56)
|
||||
$fTitel.Dispose(); $fUnter.Dispose()
|
||||
})
|
||||
|
||||
# Rippy-Adresse
|
||||
$lblHost = New-Object System.Windows.Forms.Label
|
||||
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
|
||||
$lblHost.Location = New-Object System.Drawing.Point(24, 120)
|
||||
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblHost.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblHost)
|
||||
|
||||
$txtHost = New-Object System.Windows.Forms.TextBox
|
||||
$txtHost.Location = New-Object System.Drawing.Point(24, 140)
|
||||
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
||||
$txtHost.Text = $RippyHost
|
||||
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||
$txtHost.BackColor = $cFeld
|
||||
$txtHost.ForeColor = $cText
|
||||
$txtHost.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtHost)
|
||||
|
||||
# Worker-Name
|
||||
$lblName = New-Object System.Windows.Forms.Label
|
||||
$lblName.Text = "Name dieses Workers (frei wählbar):"
|
||||
$lblName.Location = New-Object System.Drawing.Point(24, 176)
|
||||
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblName.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblName)
|
||||
|
||||
$txtName = New-Object System.Windows.Forms.TextBox
|
||||
$txtName.Location = New-Object System.Drawing.Point(24, 196)
|
||||
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
||||
$txtName.Text = $WorkerName
|
||||
$txtName.BackColor = $cFeld
|
||||
$txtName.ForeColor = $cText
|
||||
$txtName.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtName)
|
||||
|
||||
# Zielverzeichnis - frei wählbar, Standard "Programme"
|
||||
$lblPfad = New-Object System.Windows.Forms.Label
|
||||
$lblPfad.Text = "Installieren nach:"
|
||||
$lblPfad.Location = New-Object System.Drawing.Point(24, 232)
|
||||
$lblPfad.Size = New-Object System.Drawing.Size(500, 18)
|
||||
$lblPfad.ForeColor = $cGedaempft
|
||||
$form.Controls.Add($lblPfad)
|
||||
|
||||
$txtPfad = New-Object System.Windows.Forms.TextBox
|
||||
$txtPfad.Location = New-Object System.Drawing.Point(24, 252)
|
||||
$txtPfad.Size = New-Object System.Drawing.Size(390, 26)
|
||||
$txtPfad.Text = $StandardZiel
|
||||
$txtPfad.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$txtPfad.BackColor = $cFeld
|
||||
$txtPfad.ForeColor = $cText
|
||||
$txtPfad.BorderStyle = "FixedSingle"
|
||||
$form.Controls.Add($txtPfad)
|
||||
|
||||
$btnPfad = New-Object System.Windows.Forms.Button
|
||||
$btnPfad.Text = "Ändern ..."
|
||||
$btnPfad.Location = New-Object System.Drawing.Point(424, 251)
|
||||
$btnPfad.Size = New-Object System.Drawing.Size(100, 28)
|
||||
$btnPfad.FlatStyle = "Flat"
|
||||
$btnPfad.BackColor = $cFeld
|
||||
$btnPfad.ForeColor = $cText
|
||||
$btnPfad.FlatAppearance.BorderColor = $cGedaempft
|
||||
$form.Controls.Add($btnPfad)
|
||||
$btnPfad.Add_Click({
|
||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dlg.Description = "Ordner wählen - darin wird 'Rippy Worker' angelegt"
|
||||
$dlg.ShowNewFolderButton = $true
|
||||
if ($dlg.ShowDialog() -eq "OK") {
|
||||
# Der Dialog liefert den ELTERN-Ordner; der Unterordner kommt von uns,
|
||||
# damit die Deinstallation nie einen fremden Ordner mitlöscht.
|
||||
$txtPfad.Text = (Join-Path $dlg.SelectedPath "Rippy Worker")
|
||||
}
|
||||
})
|
||||
|
||||
# Autostart
|
||||
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
||||
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
||||
$chkAuto.Location = New-Object System.Drawing.Point(24, 290)
|
||||
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
||||
$chkAuto.Checked = $true
|
||||
$chkAuto.ForeColor = $cText
|
||||
$form.Controls.Add($chkAuto)
|
||||
|
||||
# Log-Bereich
|
||||
$log = New-Object System.Windows.Forms.TextBox
|
||||
$log.Location = New-Object System.Drawing.Point(24, 322)
|
||||
$log.Size = New-Object System.Drawing.Size(500, 150)
|
||||
$log.Multiline = $true
|
||||
$log.ReadOnly = $true
|
||||
$log.ScrollBars = "Vertical"
|
||||
$log.BackColor = $cPanel
|
||||
$log.ForeColor = $cText
|
||||
$log.BorderStyle = "FixedSingle"
|
||||
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
||||
$form.Controls.Add($log)
|
||||
|
||||
# Knöpfe
|
||||
$btnInstall = New-Object System.Windows.Forms.Button
|
||||
$btnInstall.Text = "Installieren"
|
||||
$btnInstall.Location = New-Object System.Drawing.Point(24, 486)
|
||||
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
||||
$btnInstall.BackColor = $cAmber
|
||||
$btnInstall.ForeColor = $cBg
|
||||
$btnInstall.FlatStyle = "Flat"
|
||||
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||
$form.Controls.Add($btnInstall)
|
||||
|
||||
$btnStart = New-Object System.Windows.Forms.Button
|
||||
$btnStart.Text = "Worker starten"
|
||||
$btnStart.Location = New-Object System.Drawing.Point(190, 486)
|
||||
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
||||
$btnStart.BackColor = $cEmerald
|
||||
$btnStart.ForeColor = [System.Drawing.Color]::White
|
||||
$btnStart.FlatStyle = "Flat"
|
||||
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||
$btnStart.Visible = $false
|
||||
$form.Controls.Add($btnStart)
|
||||
|
||||
function Log($text) {
|
||||
$log.AppendText($text + "`r`n")
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
}
|
||||
|
||||
# --- Installations-Logik ---------------------------------------------------
|
||||
function Do-Install {
|
||||
$rHost = $txtHost.Text.Trim()
|
||||
$wName = $txtName.Text.Trim()
|
||||
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
|
||||
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
|
||||
|
||||
# Zielverzeichnis aus dem Feld. script:-Bereich, weil "Worker starten"
|
||||
# und die Abschlussmeldung denselben Pfad brauchen.
|
||||
$zielEingabe = $txtPfad.Text.Trim()
|
||||
if (-not $zielEingabe) { $zielEingabe = $StandardZiel }
|
||||
if (-not [System.IO.Path]::IsPathRooted($zielEingabe)) {
|
||||
[System.Windows.Forms.MessageBox]::Show(
|
||||
"Bitte einen vollständigen Pfad angeben, z. B. C:\Program Files\Rippy Worker.",
|
||||
"Pfad unvollständig")
|
||||
return
|
||||
}
|
||||
$script:InstallDir = $zielEingabe
|
||||
|
||||
$btnInstall.Enabled = $false
|
||||
$log.Clear()
|
||||
Log "Ziel: $InstallDir"
|
||||
|
||||
try {
|
||||
# 1. Python finden
|
||||
$python = $null
|
||||
foreach ($k in @("py -3", "python")) {
|
||||
try {
|
||||
$v = Invoke-Expression "$k --version" 2>&1
|
||||
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $python) {
|
||||
Log "FEHLER: Python 3.10+ nicht gefunden."
|
||||
Log "Installieren mit: winget install Python.Python.3.12"
|
||||
Log "Danach diesen Installer erneut starten."
|
||||
$btnInstall.Enabled = $true
|
||||
return
|
||||
}
|
||||
Log "Python gefunden: $python"
|
||||
|
||||
# 2. Ordner + Worker-Code
|
||||
# Erst PRUEFEN, ob wir dort überhaupt schreiben dürfen. Die .exe ist mit
|
||||
# -requireAdmin gebaut und fragt per UAC, aber wer install-gui.ps1 direkt
|
||||
# startet, hat die Rechte nicht - dann soll hier ein brauchbarer Satz
|
||||
# stehen und nicht "Zugriff verweigert" aus irgendeiner Tiefe.
|
||||
try {
|
||||
New-Item -ItemType Directory -Force $InstallDir -ErrorAction Stop | Out-Null
|
||||
$probe = Join-Path $InstallDir ".schreibtest"
|
||||
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Log "FEHLER: In '$InstallDir' darf nicht geschrieben werden."
|
||||
Log ""
|
||||
Log "Das ist normal bei Ordnern unter 'Programme' - dafür braucht es"
|
||||
Log "Administratorrechte. Zwei Wege:"
|
||||
Log " 1. Diesen Installer mit Rechtsklick > 'Als Administrator"
|
||||
Log " ausführen' starten (der empfohlene Weg), ODER"
|
||||
Log " 2. oben über 'Ändern ...' einen Ordner im eigenen"
|
||||
Log " Benutzerprofil wählen - dort geht es ohne Adminrechte."
|
||||
$btnInstall.Enabled = $true
|
||||
return
|
||||
}
|
||||
Set-Location $InstallDir
|
||||
$HandBrakeVersion = "1.11.2"
|
||||
try {
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||
} catch {}
|
||||
|
||||
Log "Lade Worker-Code von $rHost ..."
|
||||
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||
Expand-Archive worker.zip -DestinationPath . -Force
|
||||
Remove-Item worker.zip
|
||||
|
||||
# 3. venv + Abhängigkeiten
|
||||
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
||||
Log "Installiere Python-Abhängigkeiten (kann 1-2 min dauern) ..."
|
||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||
Log "Abhängigkeiten installiert."
|
||||
|
||||
# 4. HandBrakeCLI
|
||||
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
|
||||
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
|
||||
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
|
||||
Expand-Archive hb.zip -DestinationPath . -Force
|
||||
Remove-Item hb.zip
|
||||
}
|
||||
Log "HandBrake bereit."
|
||||
|
||||
# 5. Start-Skripte + Deinstaller erzeugen
|
||||
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||
|
||||
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
||||
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
||||
|
||||
$uninstall = @"
|
||||
param([switch]`$Force)
|
||||
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||
}
|
||||
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
|
||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
||||
"@
|
||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||
|
||||
# 6. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
||||
#
|
||||
# Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create
|
||||
# mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der
|
||||
# Aufgabenplanung an, und das verlangt Administratorrechte. Der
|
||||
# Installer läuft normal ohne. schtasks schrieb deshalb
|
||||
# "FEHLER: Zugriff verweigert." nach stderr, und weil oben
|
||||
# $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der
|
||||
# GESAMTEN Installation - obwohl alles außer dem Autostart fertig war.
|
||||
# Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem
|
||||
# irreführenden Tipp, die IP könne falsch sein.
|
||||
#
|
||||
# Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die
|
||||
# Verknüpfung dort selbst sehen und löschen. Zwei Fliegen.
|
||||
if ($chkAuto.Checked) {
|
||||
try {
|
||||
# Liegt das Programm unter "Programme", ist es eine Installation
|
||||
# für die ganze Maschine - dann gehört der Autostart in den
|
||||
# Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage:
|
||||
# der Installer läuft dafür ohnehin erhöht, und bei einer
|
||||
# Erhöhung über ein FREMDES Administratorkonto wäre der
|
||||
# benutzereigene Ordner der des Admins - also der falsche.
|
||||
# Bei einer Installation ins eigene Profil bleibt es persönlich.
|
||||
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||
$programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86")
|
||||
$maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") `
|
||||
-or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase"))
|
||||
$autostartDir = if ($maschinenweit) {
|
||||
[Environment]::GetFolderPath("CommonStartup")
|
||||
} else {
|
||||
[Environment]::GetFolderPath("Startup")
|
||||
}
|
||||
$lnkPfad = Join-Path $autostartDir "RippyWorker.lnk"
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$lnk = $wsh.CreateShortcut($lnkPfad)
|
||||
$lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat")
|
||||
$lnk.WorkingDirectory = $InstallDir
|
||||
$lnk.Description = "Rippy Encoding-Worker"
|
||||
$lnk.Save()
|
||||
if ($maschinenweit) {
|
||||
Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)."
|
||||
} else {
|
||||
Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)."
|
||||
}
|
||||
} catch {
|
||||
# Nur eine Warnung: der Worker selbst ist fertig und startbar.
|
||||
Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))."
|
||||
Log " Der Worker ist trotzdem fertig installiert und läuft."
|
||||
Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen"
|
||||
Log " (Windows-Taste + R, dann shell:startup eingeben)."
|
||||
}
|
||||
}
|
||||
|
||||
Log ""
|
||||
Log "FERTIG! Ordner: $InstallDir"
|
||||
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
|
||||
$btnStart.Visible = $true
|
||||
$btnInstall.Text = "Neu installieren"
|
||||
$btnInstall.Enabled = $true
|
||||
} catch {
|
||||
Log ""
|
||||
Log "FEHLER: $_"
|
||||
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
|
||||
$btnInstall.Enabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
$btnInstall.Add_Click({ Do-Install })
|
||||
$btnStart.Add_Click({
|
||||
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
||||
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
|
||||
$form.Close()
|
||||
})
|
||||
|
||||
[void]$form.ShowDialog()
|
||||
@@ -1,211 +0,0 @@
|
||||
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
||||
#
|
||||
# Was das Skript tut (alles nach -InstallDir, Standard "C:\Program Files\Rippy Worker"):
|
||||
# 1. Prüft Python (3.10+), legt ein venv an, installiert die Abhängigkeiten
|
||||
# 2. Lädt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||
# 3. Lädt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||
#
|
||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||
# weiterhin auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||
#
|
||||
# Aufruf (PowerShell):
|
||||
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
|
||||
#
|
||||
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string]$RippyHost,
|
||||
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
|
||||
[switch]$Autostart,
|
||||
# Zielverzeichnis. Standard ist "Programme", wie bei jedem anderen Programm
|
||||
# (Commander-Wunsch 25.07.2026). Dorthin schreiben braucht Adminrechte -
|
||||
# PowerShell also "Als Administrator" starten, oder hier einen Ordner im
|
||||
# eigenen Profil angeben.
|
||||
[string]$InstallDir = (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Rippy Worker")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||
# Windows-Worker zu dem, was der Update-Check im UI meldet (früher war hier
|
||||
# eine feste 1.9.2 hardcodiert, was verwirrte). Fällt auf eine bekannte
|
||||
# Version zurück, falls die GitHub-API gerade nicht erreichbar ist.
|
||||
$HandBrakeVersion = "1.11.2"
|
||||
try {
|
||||
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||
|
||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||
|
||||
# 1. Python finden (py-Launcher oder python im PATH)
|
||||
$python = $null
|
||||
foreach ($kandidat in @("py -3", "python")) {
|
||||
try {
|
||||
$version = Invoke-Expression "$kandidat --version" 2>&1
|
||||
if ("$version" -match "Python 3\.(\d+)") {
|
||||
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (-not $python) {
|
||||
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
|
||||
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausführen)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Python gefunden: $python"
|
||||
|
||||
# 2. Zielordner + Worker-Code von der Rippy-Instanz laden
|
||||
$ziel = $InstallDir
|
||||
try {
|
||||
New-Item -ItemType Directory -Force $ziel -ErrorAction Stop | Out-Null
|
||||
$probe = Join-Path $ziel ".schreibtest"
|
||||
Set-Content -Path $probe -Value "x" -ErrorAction Stop
|
||||
Remove-Item -Force $probe -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "FEHLER: In '$ziel' darf nicht geschrieben werden." -ForegroundColor Red
|
||||
Write-Host "Das ist normal bei Ordnern unter 'Programme'. Zwei Wege:" -ForegroundColor Red
|
||||
Write-Host " 1. PowerShell als Administrator starten und erneut aufrufen, ODER" -ForegroundColor Red
|
||||
Write-Host " 2. ein eigenes Ziel angeben, z. B.:" -ForegroundColor Red
|
||||
Write-Host " -InstallDir `"`$env:LOCALAPPDATA\Rippy Worker`"" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Ziel: $ziel"
|
||||
Set-Location $ziel
|
||||
|
||||
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
|
||||
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||
Expand-Archive worker.zip -DestinationPath . -Force
|
||||
Remove-Item worker.zip
|
||||
|
||||
# 3. venv + Abhängigkeiten
|
||||
if (-not (Test-Path "venv")) {
|
||||
Write-Host "Lege Python-Umgebung an ..."
|
||||
Invoke-Expression "$python -m venv venv"
|
||||
}
|
||||
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||
# Nur für den Windows-Worker: Tray-Symbol (Status, Start/Stopp, Beenden)
|
||||
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||
Write-Host "Python-Abhängigkeiten installiert."
|
||||
|
||||
# 4. HandBrakeCLI besorgen (falls nötig)
|
||||
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
|
||||
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
|
||||
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
|
||||
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
|
||||
Expand-Archive hb.zip -DestinationPath . -Force
|
||||
Remove-Item hb.zip
|
||||
}
|
||||
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
|
||||
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "HandBrakeCLI bereit."
|
||||
|
||||
# 5. Start-Skript erzeugen
|
||||
$bat = @"
|
||||
@echo off
|
||||
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
set API_URL=http://${RippyHost}:8000
|
||||
set WORKER_NAME=$WorkerName
|
||||
set PATH=%~dp0;%PATH%
|
||||
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
|
||||
"@
|
||||
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
||||
|
||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||
$trayBat = @"
|
||||
@echo off
|
||||
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||
cd /d "%~dp0"
|
||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||
set API_URL=http://${RippyHost}:8000
|
||||
set WORKER_NAME=$WorkerName
|
||||
set RIPPY_TRAY_HOST=$RippyHost
|
||||
set PATH=%~dp0;%PATH%
|
||||
start "" venv\Scripts\pythonw.exe tray.py
|
||||
"@
|
||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||
|
||||
# 7. Deinstaller - MUSS-Kriterium: rückstandsfrei entfernbar
|
||||
$uninstall = @"
|
||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||
# meldet den Worker in Rippy ab und löscht diesen Ordner komplett.
|
||||
param([switch]`$Force)
|
||||
if (-not `$Force) {
|
||||
`$antwort = Read-Host "Rippy-Worker '$WorkerName' wirklich deinstallieren? (j/n)"
|
||||
if (`$antwort -ne "j") { Write-Host "Abgebrochen."; exit }
|
||||
}
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
`$_.ExecutablePath -like "`$PSScriptRoot*"
|
||||
} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
||||
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
||||
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
||||
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
||||
}
|
||||
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||
try {
|
||||
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
||||
Write-Host "Worker-Eintrag in Rippy entfernt."
|
||||
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
|
||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
||||
"@
|
||||
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||
Write-Host "uninstall.ps1 erzeugt."
|
||||
|
||||
# 8. Optional: Autostart bei Anmeldung (mit Tray)
|
||||
#
|
||||
# Über den Autostart-ORDNER, nicht über schtasks: Eine Aufgabe im
|
||||
# Wurzelordner der Aufgabenplanung anzulegen verlangt Administratorrechte, und
|
||||
# der Installer läuft normal ohne. Auf dem Commander-PC scheiterte das am
|
||||
# 25.07.2026 mit "FEHLER: Zugriff verweigert." - und weil oben
|
||||
# $ErrorActionPreference = "Stop" steht, riss es die ganze Installation mit,
|
||||
# obwohl nur der Autostart fehlte. Der Autostart-Ordner braucht nie Adminrechte.
|
||||
if ($Autostart) {
|
||||
try {
|
||||
# Unter "Programme" = Installation für die Maschine, also Autostart für
|
||||
# alle Benutzer. Sonst persönlich. Siehe install-gui.ps1 für die
|
||||
# ausführliche Begründung (Rechtelage bei fremdem Admin-Konto).
|
||||
$programme = [Environment]::GetFolderPath("ProgramFiles")
|
||||
$maschinenweit = $ziel.StartsWith($programme, "OrdinalIgnoreCase")
|
||||
$autostartDir = if ($maschinenweit) {
|
||||
[Environment]::GetFolderPath("CommonStartup")
|
||||
} else {
|
||||
[Environment]::GetFolderPath("Startup")
|
||||
}
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$lnk = $wsh.CreateShortcut((Join-Path $autostartDir "RippyWorker.lnk"))
|
||||
$lnk.TargetPath = (Join-Path $ziel "start-tray.bat")
|
||||
$lnk.WorkingDirectory = $ziel
|
||||
$lnk.Description = "Rippy Encoding-Worker"
|
||||
$lnk.Save()
|
||||
Write-Host "Autostart eingerichtet (Verknüpfung im Autostart-Ordner)."
|
||||
} catch {
|
||||
Write-Host "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." -ForegroundColor Yellow
|
||||
Write-Host " Der Worker ist trotzdem fertig installiert." -ForegroundColor Yellow
|
||||
Write-Host " Von Hand: start-tray.bat in shell:startup verknüpfen." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "FERTIG." -ForegroundColor Green
|
||||
Write-Host " Starten (mit Tray-Symbol): $ziel\start-tray.bat"
|
||||
Write-Host " Starten (Konsole/Debug): $ziel\start-worker.bat"
|
||||
Write-Host " Deinstallieren: $ziel\uninstall.ps1"
|
||||
Write-Host "Der Worker erscheint in Rippy unter Einstellungen -> Worker als '$WorkerName'."
|
||||
@@ -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.
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 31 KiB |
+26
-12
@@ -3,7 +3,24 @@
|
||||
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
|
||||
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
|
||||
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
|
||||
# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve).
|
||||
# mit EPERM.
|
||||
#
|
||||
# ⚠️ ABER NICHT HIER (Vorfall 28.08.2026): Ein `devices:`-Eintrag ist eine
|
||||
# STARTBEDINGUNG. Als das USB-Laufwerk von der VM abgezogen wurde, legte ein
|
||||
# ganz normaler Deploy api, worker UND ui still — obwohl keiner der drei zum
|
||||
# Starten ein Laufwerk braucht:
|
||||
#
|
||||
# Error response from daemon: error gathering device information while
|
||||
# adding custom device "/dev/sr0": no such file or directory
|
||||
#
|
||||
# Der Widerspruch war älter: install.sh sagt bei fehlendem Laufwerk
|
||||
# ausdrücklich „läuft trotzdem durch, dann ist das eine reine
|
||||
# KOMPRIMIER-Maschine" — diese Datei sah das anders.
|
||||
#
|
||||
# Deshalb stehen die Geräte jetzt in docker-compose.override.yml, erzeugt von
|
||||
# `deploy/geraete-override.sh`. Compose zieht die Override von allein dazu;
|
||||
# `docker compose up -d` bleibt derselbe Befehl. Nach jedem An- oder Abstecken
|
||||
# eines Laufwerks das Skript erneut aufrufen.
|
||||
|
||||
services:
|
||||
api:
|
||||
@@ -21,6 +38,7 @@ services:
|
||||
# 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
|
||||
@@ -53,8 +71,7 @@ services:
|
||||
- temp:/app/temp
|
||||
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||
devices:
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
# devices: siehe Kopf — kommen aus docker-compose.override.yml
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -102,6 +119,7 @@ services:
|
||||
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||
- API_URL=http://api:8000
|
||||
- LOG_LEVEL=INFO
|
||||
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /srv/rippy/media
|
||||
@@ -116,15 +134,11 @@ services:
|
||||
# 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:
|
||||
# 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
|
||||
# devices: siehe Kopf dieser Datei — die optischen Knoten dieses Hosts
|
||||
# stehen in docker-compose.override.yml (deploy/geraete-override.sh).
|
||||
# MakeMKV braucht BEIDE: /dev/srN und den passenden /dev/sgM; welche
|
||||
# sg-Nummer dazugehoert, ist je Host anders und wird dort ueber die
|
||||
# SCSI-Adresse abgeglichen statt geraten.
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
|
||||
+15
-1
@@ -17,11 +17,25 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY docker/api/ .
|
||||
|
||||
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
|
||||
# neben den API-Modulen. /app ist Arbeitsverzeichnis und uvicorn-App-Dir,
|
||||
# damit findet "import rippy" das Paket ohne PYTHONPATH.
|
||||
COPY src/rippy ./rippy
|
||||
|
||||
# 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/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
|
||||
# Der Worker importiert seit V2-0 aus dem gemeinsamen Paket (tasks.py, caps.py).
|
||||
# Ohne diese Zeile fehlte es im Zip und der Windows-Worker stürbe beim Start
|
||||
# mit ModuleNotFoundError — worker_setup_paket packt genau diesen Ordner mit ein.
|
||||
COPY src/rippy worker_dist/rippy
|
||||
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.
|
||||
|
||||
Vendored
+98
-18
@@ -1,42 +1,122 @@
|
||||
"""Cache module."""
|
||||
"""Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt.
|
||||
|
||||
## Warum das nicht mehr scheitern darf (Befund 28.08.2026)
|
||||
|
||||
Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis`
|
||||
— der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem
|
||||
Windows-PC gibt es kein Redis, und jeder Aufruf endete mit:
|
||||
|
||||
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
|
||||
|
||||
Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache:
|
||||
Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an.
|
||||
|
||||
**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist
|
||||
keiner da, wird eben jedes Mal neu gefragt — TMDB und OMDb halten das aus.
|
||||
Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als
|
||||
wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es
|
||||
nicht.
|
||||
|
||||
## Warum trotzdem einmal gemeldet wird
|
||||
|
||||
Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare
|
||||
Verlangsamung — und auf der VM wäre ein weggefallenes Redis ein echter
|
||||
Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene
|
||||
Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre
|
||||
Lärm.
|
||||
"""
|
||||
|
||||
from redis import Redis
|
||||
from typing import Any, Optional
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
redis_host = os.getenv("REDIS_HOST", "redis")
|
||||
redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True)
|
||||
# Kurze Zeitgrenzen: Ein Zwischenspeicher, auf den man wartet, ist keiner.
|
||||
# Ohne sie hing jeder Aufruf am Standard-Timeout der Bibliothek.
|
||||
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True,
|
||||
socket_connect_timeout=1.5, socket_timeout=1.5)
|
||||
|
||||
# Erst beim ersten Zugriff bekannt. None = noch nichts versucht.
|
||||
_erreichbar: Optional[bool] = None
|
||||
|
||||
|
||||
def _melde_einmal(fehler: Exception) -> None:
|
||||
"""Beim ERSTEN Fehlschlag eine Zeile, danach Ruhe."""
|
||||
global _erreichbar
|
||||
if _erreichbar is False:
|
||||
return
|
||||
_erreichbar = False
|
||||
print("Zwischenspeicher (%s:%s) nicht erreichbar — es wird ohne "
|
||||
"gearbeitet: %s" % (redis_host, redis_port, fehler))
|
||||
|
||||
|
||||
def zustand() -> dict:
|
||||
"""Woran man ist. Für Diagnose und Selbstauskunft."""
|
||||
return {"host": redis_host, "port": redis_port, "erreichbar": _erreichbar}
|
||||
|
||||
|
||||
def init_cache() -> None:
|
||||
"""Initialize cache connection."""
|
||||
pass
|
||||
"""Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie."""
|
||||
global _erreichbar
|
||||
try:
|
||||
redis_client.ping()
|
||||
_erreichbar = True
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
|
||||
|
||||
def get(key: str) -> Optional[Any]:
|
||||
"""Get value from cache."""
|
||||
value = redis_client.get(key)
|
||||
"""Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher."""
|
||||
global _erreichbar
|
||||
try:
|
||||
value = redis_client.get(key)
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return None
|
||||
_erreichbar = True
|
||||
if value:
|
||||
return json.loads(value)
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
# Ein kaputter Eintrag ist kein Grund, den Aufrufer scheitern zu
|
||||
# lassen — er holt sich die Antwort dann eben frisch.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
|
||||
"""Set value in cache."""
|
||||
serialized = json.dumps(value)
|
||||
if expire:
|
||||
return redis_client.setex(key, expire, serialized)
|
||||
return redis_client.set(key, serialized)
|
||||
"""Wert ablegen. False heißt: ging nicht — und das ist in Ordnung."""
|
||||
global _erreichbar
|
||||
try:
|
||||
serialized = json.dumps(value)
|
||||
if expire:
|
||||
return bool(redis_client.setex(key, expire, serialized))
|
||||
return bool(redis_client.set(key, serialized))
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# Nicht serialisierbar. Das ist ein Fehler des Aufrufers, aber keiner,
|
||||
# der eine Metadaten-Abfrage umwerfen darf.
|
||||
return False
|
||||
|
||||
|
||||
def delete(key: str) -> bool:
|
||||
"""Delete value from cache."""
|
||||
return redis_client.delete(key)
|
||||
try:
|
||||
return bool(redis_client.delete(key))
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return False
|
||||
|
||||
|
||||
def clear() -> bool:
|
||||
"""Clear all cache."""
|
||||
return redis_client.flushdb()
|
||||
try:
|
||||
return bool(redis_client.flushdb())
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return False
|
||||
|
||||
@@ -2,16 +2,105 @@
|
||||
|
||||
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
|
||||
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
||||
|
||||
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
|
||||
|
||||
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
|
||||
Import von `main.py` ein funktionierendes Celery — auch dann, wenn nie ein Rip
|
||||
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
|
||||
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
|
||||
Start:
|
||||
|
||||
File "celery_client.py", ...
|
||||
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
|
||||
ModuleNotFoundError: No module named 'celery.fixups'
|
||||
|
||||
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
|
||||
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
|
||||
|
||||
**Was das für den Windows-Betrieb bedeutet — ehrlich gesagt:** Die Oberfläche,
|
||||
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
|
||||
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
|
||||
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
|
||||
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
|
||||
"""
|
||||
|
||||
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)
|
||||
_client = None
|
||||
|
||||
# Wer Auftraege zustellt. None = Celery (verteilter Betrieb).
|
||||
# Der Standalone-Betrieb setzt hier seine eigene Zustellung ein — siehe
|
||||
# `zusteller_setzen()`.
|
||||
_zusteller = None
|
||||
|
||||
|
||||
class KeinBroker(RuntimeError):
|
||||
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
|
||||
|
||||
|
||||
def hole_client():
|
||||
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
|
||||
global _client
|
||||
if _client is None:
|
||||
try:
|
||||
from celery import Celery
|
||||
except ImportError as e:
|
||||
raise KeinBroker(
|
||||
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
|
||||
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
|
||||
"Broker ist damit nicht möglich (Umstellung auf die lokale "
|
||||
"Auftrags-Queue: Etappe V2-5)."
|
||||
) from e
|
||||
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||
return _client
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""`celery_client` von außen — baut den Client bei Bedarf.
|
||||
|
||||
Damit bleiben die bestehenden `from celery_client import celery_client`
|
||||
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
|
||||
"""
|
||||
if name == "celery_client":
|
||||
return hole_client()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def zusteller_setzen(funktion) -> None:
|
||||
"""Legt fest, WER Auftraege bekommt.
|
||||
|
||||
Ohne Aufruf geht alles an Celery — das ist der Docker-Betrieb, unveraendert.
|
||||
Der Standalone-Betrieb (Windows-App, Headless-Linux) setzt hier seine
|
||||
lokale Auftrags-Queue ein; dann laeuft die Arbeit im selben Prozess.
|
||||
|
||||
Die Unterschrift ist die von `abschicken`:
|
||||
|
||||
funktion(task_name, args, queue=None) -> irgendetwas
|
||||
|
||||
Das ist bewusst dieselbe Form, die Celery hat. So muss keine Aufrufstelle
|
||||
wissen, in welchem Betrieb sie gerade laeuft.
|
||||
"""
|
||||
global _zusteller
|
||||
_zusteller = funktion
|
||||
|
||||
|
||||
def abschicken(task_name: str, args: list, queue: str = None):
|
||||
"""Einen Auftrag zustellen — an Celery oder an die lokale Queue.
|
||||
|
||||
EINE Stelle fuer alle drei Auftragsarten (rip_disc, transcode_files,
|
||||
scan_tracks). Vorher rief jede Aufrufstelle `send_task` selbst auf; damit
|
||||
haette der Standalone-Betrieb an drei Stellen umgebogen werden muessen —
|
||||
und beim naechsten Auftragstyp an einer vierten.
|
||||
"""
|
||||
if _zusteller is not None:
|
||||
return _zusteller(task_name, args, queue)
|
||||
kwargs = {"args": args}
|
||||
if queue:
|
||||
kwargs["queue"] = queue
|
||||
return hole_client().send_task(task_name, **kwargs)
|
||||
|
||||
|
||||
def transcode_queue(node: str = None):
|
||||
@@ -25,7 +114,9 @@ def transcode_queue(node: str = None):
|
||||
if not node:
|
||||
return "transcode"
|
||||
try:
|
||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||
from celery.utils import worker_direct
|
||||
|
||||
antworten = hole_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)
|
||||
@@ -35,7 +126,5 @@ def transcode_queue(node: str = None):
|
||||
|
||||
|
||||
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||
return celery_client.send_task(
|
||||
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
||||
)
|
||||
"""Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
|
||||
return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
|
||||
|
||||
@@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float:
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -33,8 +33,13 @@ def parse_year(year: str) -> Optional[int]:
|
||||
class OMDbClient:
|
||||
def __init__(self):
|
||||
# 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
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||
# gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
|
||||
# "from rippy import store as db", dieses Modul hat es nie
|
||||
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
|
||||
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
|
||||
from rippy.store import get_settings
|
||||
self.api_key = get_settings(bei_fehler_leer=True).get("omdbApiKey") or settings.omdb_api_key
|
||||
self.session = requests.Session()
|
||||
|
||||
def suche(self, title: str) -> list:
|
||||
|
||||
@@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
|
||||
class TheTVDBClient:
|
||||
def __init__(self):
|
||||
# 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
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||
# gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
|
||||
# "from rippy import store as db", dieses Modul hat es nie
|
||||
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
|
||||
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
|
||||
from rippy.store import get_settings
|
||||
self.api_key = get_settings(bei_fehler_leer=True).get("tvdbApiKey") or settings.thetvdb_api_key
|
||||
self.base_url = THETVDB_BASE_URL
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
|
||||
@@ -24,8 +24,20 @@ class TMDBClient:
|
||||
def __init__(self):
|
||||
# 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
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
|
||||
#
|
||||
# Hier stand "from db import get_settings". docker/api/db.py gibt es
|
||||
# seit der Zusammenlegung in V2-1 (dd1d0b7) nicht mehr — main.py
|
||||
# schreibt seitdem "from rippy import store as db", aber dieses Modul
|
||||
# hat das nie mitbekommen. Die Folge: JEDE Metadaten-Abfrage starb
|
||||
# schon beim Erzeugen des Clients mit ModuleNotFoundError, und
|
||||
# _auto_prescan verschluckte das in seinem "except Exception". Discs
|
||||
# blieben namenlos — auf Windows UND auf der VM.
|
||||
#
|
||||
# bei_fehler_leer=True: Ohne Datenbank gilt der Schlüssel aus der
|
||||
# Umgebung. Ein Metadaten-Client ist kein Grund, warum nichts geht.
|
||||
from rippy.store import get_settings
|
||||
self.api_key = get_settings(bei_fehler_leer=True).get("tmdbApiKey") or settings.tmdb_api_key
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Content-Type": "application/json"})
|
||||
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Job-, Log- und Settings-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
||||
|
||||
Die jobs/logs-Tabellendefinition existiert bewusst identisch im Worker
|
||||
(docker/worker/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
||||
|
||||
Bis 23.07. lag Postgres komplett brach: GET /jobs gab hart [] zurück, nichts
|
||||
schrieb je eine Zeile — der Job-Verlauf im UI war ein Placebo.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
select,
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
metadata = MetaData()
|
||||
|
||||
jobs = Table(
|
||||
"jobs",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("disc_type", String(16)),
|
||||
Column("device", String(64)),
|
||||
Column("title", String(255)),
|
||||
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)),
|
||||
)
|
||||
|
||||
logs = Table(
|
||||
"logs",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", DateTime(timezone=True)),
|
||||
Column("level", String(16)),
|
||||
Column("source", String(32)),
|
||||
Column("message", 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)),
|
||||
)
|
||||
|
||||
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) 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,
|
||||
target_dir: str = None, meta: str = None,
|
||||
) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
jobs.insert().values(
|
||||
id=job_id,
|
||||
device=device,
|
||||
disc_type=disc_type,
|
||||
title=title,
|
||||
target_dir=target_dir,
|
||||
meta=meta,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_at=utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
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 has_active_job(device: str) -> bool:
|
||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(jobs.c.id)
|
||||
.where(jobs.c.device == device)
|
||||
.where(jobs.c.status.in_(("pending", "running")))
|
||||
.limit(1)
|
||||
).first()
|
||||
return zeile is not None
|
||||
|
||||
|
||||
def add_log(level: str, source: str, message: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||
)
|
||||
|
||||
|
||||
def list_logs(limit: int = 200) -> list:
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(logs).order_by(logs.c.id.desc()).limit(limit)
|
||||
).mappings().all()
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def get_settings(key: str = "ui") -> dict:
|
||||
with engine.connect() as conn:
|
||||
zeile = conn.execute(
|
||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||
).first()
|
||||
if not zeile or not zeile[0]:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(zeile[0])
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
|
||||
def save_settings(werte: dict, key: str = "ui") -> None:
|
||||
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))
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
|
||||
|
||||
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
|
||||
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices — die Ausgabe ist
|
||||
immer nur "block special", die Erkennung lieferte also strukturell IMMER
|
||||
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
|
||||
bewies damit nichts.
|
||||
|
||||
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
|
||||
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
from fcntl import ioctl
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROM_DRIVE_STATUS = 0x5326
|
||||
CDROM_DISC_STATUS = 0x5327
|
||||
|
||||
CDS_NO_DISC = 1
|
||||
CDS_TRAY_OPEN = 2
|
||||
CDS_DRIVE_NOT_READY = 3
|
||||
CDS_DISC_OK = 4
|
||||
|
||||
CDS_AUDIO = 100
|
||||
CDS_DATA_1 = 101
|
||||
CDS_DATA_2 = 102
|
||||
CDS_XA_2_1 = 103
|
||||
CDS_XA_2_2 = 104
|
||||
CDS_MIXED = 105
|
||||
|
||||
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
|
||||
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:
|
||||
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
|
||||
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
|
||||
def drive_status(device_path: str) -> int:
|
||||
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_status(device_path: str) -> int:
|
||||
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
return ioctl(fd, CDROM_DISC_STATUS, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def disc_size_bytes(device_path: str) -> int:
|
||||
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
|
||||
fd = _open_nonblock(device_path)
|
||||
try:
|
||||
buf = bytearray(8)
|
||||
ioctl(fd, BLKGETSIZE64, buf)
|
||||
return struct.unpack("Q", bytes(buf))[0]
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def classify(disc_status_code: int, size_bytes: int) -> str:
|
||||
"""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"
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
|
||||
try:
|
||||
if drive_status(device_path) != CDS_DISC_OK:
|
||||
return "no_disc"
|
||||
return classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||
except OSError:
|
||||
return "unknown"
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Laufwerks-Discovery über /sys und ioctls — ehrlich, ohne udev.
|
||||
|
||||
Warum kein udevadm mehr: Im Container läuft kein udevd, die udev-Datenbank ist
|
||||
leer — `udevadm info` lieferte schlicht nichts (der alte Weg zeigte deshalb nie
|
||||
ein Laufwerk an). /sys/class/block/<name>/device/{vendor,model} kommt dagegen
|
||||
direkt vom Kernel und funktioniert überall.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
from fcntl import ioctl
|
||||
|
||||
from detection import (
|
||||
CDS_DISC_OK,
|
||||
classify,
|
||||
disc_size_bytes,
|
||||
disc_status,
|
||||
drive_status,
|
||||
)
|
||||
|
||||
# include/uapi/linux/cdrom.h
|
||||
CDROMEJECT = 0x5309
|
||||
|
||||
|
||||
def eject(device_path: str) -> None:
|
||||
"""Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def list_optical_devices() -> list:
|
||||
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
||||
return sorted(glob.glob("/dev/sr[0-9]*"))
|
||||
|
||||
|
||||
def read_sys_attr(device_name: str, attr: str) -> str:
|
||||
pfad = f"/sys/class/block/{device_name}/device/{attr}"
|
||||
try:
|
||||
with open(pfad, encoding="ascii", errors="replace") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def device_info(device_path: str) -> dict:
|
||||
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl."""
|
||||
name = os.path.basename(device_path)
|
||||
vendor = read_sys_attr(name, "vendor")
|
||||
model = read_sys_attr(name, "model")
|
||||
|
||||
try:
|
||||
status_code = drive_status(device_path)
|
||||
except OSError:
|
||||
status_code = -1
|
||||
|
||||
disc_type = "unknown"
|
||||
status = "empty"
|
||||
if status_code == CDS_DISC_OK:
|
||||
status = "ready"
|
||||
try:
|
||||
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||
except OSError:
|
||||
disc_type = "unknown"
|
||||
|
||||
return {
|
||||
"id": name,
|
||||
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
|
||||
"type": disc_type,
|
||||
"path": device_path,
|
||||
"status": status,
|
||||
"model": model,
|
||||
"serial": read_sys_attr(name, "wwid"),
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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}"
|
||||
|
||||
|
||||
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||||
#:
|
||||
#: ## Der Befund des Commanders (29.08.2026)
|
||||
#:
|
||||
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||
#:
|
||||
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||||
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||||
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||||
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||||
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||||
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||||
#:
|
||||
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||||
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||||
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||||
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||||
_REIHEN: dict = {}
|
||||
|
||||
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||||
REIHE_HALTBAR_SEKUNDEN = 86400
|
||||
|
||||
|
||||
def _aufraeumen(jetzt: float) -> None:
|
||||
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||||
for k, (stand, _) in list(_REIHEN.items()):
|
||||
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||||
_REIHEN.pop(k, None)
|
||||
|
||||
|
||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||
jetzt: float, cache_get, cache_set) -> dict:
|
||||
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||||
|
||||
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||
"""
|
||||
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
|
||||
if not reihe:
|
||||
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||||
eintrag = _REIHEN.get(k)
|
||||
reihe = eintrag[1] if eintrag else None
|
||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||
try:
|
||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||
except Exception:
|
||||
pass
|
||||
_REIHEN[k] = (jetzt, reihe)
|
||||
_aufraeumen(jetzt)
|
||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||
+1704
-109
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@ def fetch_current_key(timeout: int = 15) -> str | None:
|
||||
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
|
||||
from rippy import store as db
|
||||
einstellungen = db.get_settings()
|
||||
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
||||
return False
|
||||
@@ -61,7 +61,7 @@ def _apply_key(key: str) -> bool:
|
||||
|
||||
async def refresh_once() -> bool:
|
||||
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
||||
import db
|
||||
from rippy import store as db
|
||||
key = await asyncio.to_thread(fetch_current_key)
|
||||
if not key:
|
||||
db.add_log("warning", "makemkv-key",
|
||||
@@ -77,7 +77,7 @@ async def refresh_once() -> bool:
|
||||
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
|
||||
from rippy import store as db
|
||||
await asyncio.sleep(start_verzoegerung_s)
|
||||
while True:
|
||||
try:
|
||||
|
||||
+237
-46
@@ -11,18 +11,30 @@ sondern über eine temporäre credentials-Datei).
|
||||
"""
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import db
|
||||
from rippy import store as db
|
||||
|
||||
MEDIA_ROOT = "/app/media"
|
||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
||||
|
||||
|
||||
def _mountpoint(name: str) -> str:
|
||||
return os.path.join(MEDIA_ROOT, name)
|
||||
"""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:
|
||||
@@ -50,13 +62,69 @@ def ist_erreichbar(name: str) -> bool:
|
||||
ziel = _mountpoint(name)
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
||||
["timeout", "3", "ls", ziel], capture_output=True,
|
||||
creationflags=OHNE_FENSTER, 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,
|
||||
creationflags=OHNE_FENSTER, 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?
|
||||
|
||||
@@ -94,6 +162,18 @@ def _stale_mounts_loesen(ziel: str) -> int:
|
||||
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
|
||||
|
||||
|
||||
@@ -161,6 +241,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
||||
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.
|
||||
@@ -169,17 +308,40 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
Mount-Fehlern.
|
||||
"""
|
||||
ziel = _mountpoint(name)
|
||||
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||
try:
|
||||
os.makedirs(ziel, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
except OSError:
|
||||
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||
# 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).
|
||||
@@ -209,18 +371,43 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
else:
|
||||
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||
|
||||
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}")
|
||||
return schreibtest(ziel)
|
||||
# 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:
|
||||
@@ -237,22 +424,25 @@ def _lazy_umount(ziel: str) -> None:
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
noch_mount = os.path.ismount(ziel)
|
||||
except OSError:
|
||||
noch_mount = True # stale
|
||||
if noch_mount:
|
||||
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)
|
||||
try:
|
||||
os.rmdir(ziel)
|
||||
except OSError:
|
||||
pass # nicht leer oder weg — egal
|
||||
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 = "",
|
||||
@@ -261,13 +451,14 @@ def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
|
||||
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)
|
||||
try:
|
||||
if os.path.ismount(ziel):
|
||||
_lazy_umount(ziel)
|
||||
except OSError:
|
||||
_lazy_umount(ziel)
|
||||
_lazy_umount(ziel)
|
||||
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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,80 @@
|
||||
"""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_von(meta_json) -> dict:
|
||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||
|
||||
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||
"""
|
||||
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_von(job.get("meta")).get(RIP_FERTIG)
|
||||
if marke is True:
|
||||
return NEU_KOMPRIMIEREN
|
||||
if marke is False:
|
||||
return NEU_RIPPEN
|
||||
return UNKLAR
|
||||
+219
-58
@@ -6,11 +6,11 @@ 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 rippy import drives as _laufwerks_schicht
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from clients.tmdb import TMDBClient
|
||||
from clients.jikan import JikanClient
|
||||
from clients.musicbrainz import MusicBrainzClient
|
||||
@@ -19,6 +19,18 @@ from clients.thetvdb import TheTVDBClient
|
||||
from cache import get, set as cache_set
|
||||
from cache.keys import generate_prescan_key
|
||||
|
||||
# Der Treiber DIESER Maschine statt fest der Linux-Fassung — sonst waere
|
||||
# dieses Modul (und ueber es main.py) unter Windows nicht ladbar. Beide
|
||||
# Treiber bieten disc_size_bytes() und detect_disc_type() an; darauf achtet
|
||||
# test_treiberwahl.py.
|
||||
detection = _laufwerks_schicht.treiber()
|
||||
|
||||
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
|
||||
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
|
||||
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
|
||||
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
|
||||
# Neuauflagen und Regie-Fassungen desselben Films.
|
||||
WENIGE_TREFFER = 3
|
||||
|
||||
def normalize_disc_label(label: str) -> str:
|
||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||
@@ -75,50 +87,95 @@ def parse_udf_dstring(data: bytes) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def disc_wurzel(device_path: str):
|
||||
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
|
||||
|
||||
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
|
||||
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
|
||||
|
||||
## Warum das getrennt ist (28.08.2026)
|
||||
|
||||
Der Weg hier war fest `mount -t udf` — also Linux. Unter Windows haengt
|
||||
Windows die Disc selbst ein: `\.\G:` entspricht `G:\`, und dort liegen
|
||||
die Dateien einfach da. Ohne diese Unterscheidung fand Rippy unter
|
||||
Windows NIE den Klartext-Titel und blieb beim Volume-Label haengen
|
||||
(gemessen an einer echten Disc: `BD_EVG_D2` statt `Evangelion 2.22`) --
|
||||
und mit so einem Label findet keine Metadaten-API etwas.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
# \.\G: -> G:\ (der Buchstabe ist alles, was wir brauchen)
|
||||
buchstabe = device_path.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
||||
wurzel = buchstabe + ":" + "\\"
|
||||
return (wurzel, lambda: None) if os.path.isdir(wurzel) else (None, lambda: None)
|
||||
|
||||
mountpoint = "/mnt/rippy-disc"
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
ergebnis = subprocess.run(
|
||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
return None, lambda: None
|
||||
|
||||
def abhaengen():
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
|
||||
creationflags=OHNE_FENSTER)
|
||||
|
||||
return mountpoint, abhaengen
|
||||
|
||||
|
||||
def titel_aus_bdmt(wurzel: str) -> Optional[str]:
|
||||
"""Der Klartext-Titel aus BDMV/META/DL/bdmt_*.xml. (ohne Mounten)
|
||||
|
||||
Getrennt von der Beschaffung der Wurzel, damit er ohne Disc pruefbar ist:
|
||||
ein Ordner mit einer bdmt-Datei reicht.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
meta_dir = os.path.join(wurzel, "BDMV", "META", "DL")
|
||||
if not os.path.isdir(meta_dir):
|
||||
return None
|
||||
try:
|
||||
kandidaten = sorted(os.listdir(meta_dir))
|
||||
except OSError:
|
||||
return None
|
||||
# bdmt_eng.xml bevorzugen, sonst die erste bdmt-Datei (bdmt_deu.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
|
||||
try:
|
||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
# <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)
|
||||
return treffer.group(1).strip() if treffer else None
|
||||
|
||||
|
||||
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).
|
||||
Das Volume-Label ist oft kryptisch (`BD_EVG_D2`) - der echte Titel
|
||||
(„Evangelion 2.22") steht in den Disc-Metadaten. Unter Linux wird dafuer
|
||||
read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
|
||||
Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
||||
"""
|
||||
import re as _re
|
||||
mountpoint = "/mnt/rippy-disc"
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
gemountet = False
|
||||
wurzel, aufraeumen = None, lambda: None
|
||||
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
|
||||
wurzel, aufraeumen = disc_wurzel(device_path)
|
||||
return titel_aus_bdmt(wurzel) if wurzel else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if gemountet:
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
||||
try:
|
||||
aufraeumen()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def titel_kandidaten(titel: str) -> List[str]:
|
||||
@@ -274,7 +331,8 @@ class PreScan:
|
||||
["cdparanoia", "-Q", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
timeout=10,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'track' in line.lower():
|
||||
@@ -298,22 +356,37 @@ class PreScan:
|
||||
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", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if line.startswith('TINFO:'):
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 5:
|
||||
toc["tracks"].append({
|
||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
||||
})
|
||||
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||
#
|
||||
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||
#
|
||||
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||
# lange. Das ging mal viel schneller."
|
||||
#
|
||||
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||
#
|
||||
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||
# `shutil.which`, und das findet unter Windows nichts
|
||||
# (Programme liegen nicht im PATH). `be3fac5` hat das am
|
||||
# 28.08.2026 richtig repariert — und damit erst die Kosten
|
||||
# sichtbar gemacht, die hier immer schon standen.
|
||||
#
|
||||
# Der Aufwand war umsonst: Das Ergebnis landete allein in
|
||||
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
|
||||
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
|
||||
# und `GET /devices/{id}/tracks` — also dann, wenn sie
|
||||
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
|
||||
# Der Weg fuer eine Disc, die man gar nicht rippen will,
|
||||
# sind so zwei Minuten Warten fuer nichts.
|
||||
#
|
||||
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
|
||||
# billig. Wer den Aufruf je wieder braucht, braucht dazu
|
||||
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
|
||||
# `tools.katalog` — die Importe sind mit ihm gegangen.
|
||||
except Exception as e:
|
||||
print(f"Pre-Scan TOC Error: {e}")
|
||||
return toc
|
||||
@@ -390,11 +463,13 @@ class PreScan:
|
||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||
kandidaten = titel_kandidaten(title) or [title]
|
||||
movies = []
|
||||
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||
|
||||
for kandidat in kandidaten:
|
||||
treffer = self.tmdb.search_movie(kandidat)
|
||||
if treffer and not movies:
|
||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||
movies_kandidat = kandidat
|
||||
for movie in treffer:
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
confidence = 0.95
|
||||
@@ -441,16 +516,93 @@ class PreScan:
|
||||
if matched:
|
||||
break
|
||||
|
||||
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
|
||||
#
|
||||
# ## Der Befund des Commanders (28.08.2026)
|
||||
#
|
||||
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
|
||||
# > wir ein cover."
|
||||
#
|
||||
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
|
||||
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
|
||||
# == kandidat.lower()`. An seiner Disc gemessen:
|
||||
#
|
||||
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||
# 'Evangelion' 20 Treffer irgendein Evangelion
|
||||
#
|
||||
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
|
||||
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
|
||||
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
|
||||
#
|
||||
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
|
||||
#
|
||||
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
|
||||
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
|
||||
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
|
||||
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
|
||||
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
|
||||
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
|
||||
#
|
||||
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
|
||||
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
|
||||
# ehrlich unter einem wörtlichen Treffer (0,95).
|
||||
if not matched and movies and movies_kandidat == kandidaten[0] \
|
||||
and len(movies) <= WENIGE_TREFFER:
|
||||
details = self.tmdb.get_movie_details(movies[0]["id"])
|
||||
if details:
|
||||
confidence = 0.8
|
||||
metadata = {
|
||||
"type": "movie",
|
||||
"id": movies[0]["id"],
|
||||
"title": details.get("title", title),
|
||||
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
|
||||
"overview": details.get("overview", ""),
|
||||
"poster_path": details.get("poster_path", ""),
|
||||
"backdrop_path": details.get("backdrop_path", ""),
|
||||
"runtime": details.get("runtime", 0),
|
||||
"genres": [g["name"] for g in details.get("genres", [])],
|
||||
"source": "tmdb",
|
||||
}
|
||||
matched = True
|
||||
|
||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1
|
||||
# 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 jikan_treffer:
|
||||
if not jikan_treffer:
|
||||
continue
|
||||
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
|
||||
confidence = 0.85
|
||||
metadata = jikan_treffer
|
||||
matched = True
|
||||
break
|
||||
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)
|
||||
@@ -464,6 +616,15 @@ class PreScan:
|
||||
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)
|
||||
if not matched and movies:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
+92
-7
@@ -2,34 +2,119 @@
|
||||
|
||||
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
|
||||
from collections import defaultdict
|
||||
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()
|
||||
window_start = current_time - window_seconds
|
||||
|
||||
|
||||
# Bereinige alte Einträge
|
||||
rate_limit_store[client_id] = [
|
||||
timestamp for timestamp in rate_limit_store[client_id]
|
||||
if timestamp > window_start
|
||||
]
|
||||
|
||||
|
||||
# Prüfe ob Limit erreicht
|
||||
if len(rate_limit_store[client_id]) >= max_requests:
|
||||
return False
|
||||
|
||||
|
||||
# Füge neuen Request hinzu
|
||||
rate_limit_store[client_id].append(current_time)
|
||||
return True
|
||||
@@ -39,12 +124,12 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
||||
"""Hole verbleibende Requests."""
|
||||
current_time = time.time()
|
||||
window_start = current_time - 60
|
||||
|
||||
|
||||
current_count = len([
|
||||
timestamp for timestamp in rate_limit_store[client_id]
|
||||
if timestamp > window_start
|
||||
])
|
||||
|
||||
|
||||
return max(0, max_requests - current_count)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""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 os
|
||||
import subprocess
|
||||
|
||||
from rippy import pfade as _pfade
|
||||
|
||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||||
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||||
RAW_STANDARD = "/app/temp/raw"
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
|
||||
def _ui_einstellungen() -> dict:
|
||||
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||
gerade nicht antwortet.
|
||||
|
||||
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||
Vorgabe, wie bisher.
|
||||
"""
|
||||
try:
|
||||
from rippy import store as db
|
||||
|
||||
return db.get_settings(bei_fehler_leer=True) or {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def wurzeln(werte=None) -> tuple:
|
||||
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||
|
||||
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||
|
||||
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||
`/app/media`.
|
||||
|
||||
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||
4K-UHD bis zu 100 GB.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
|
||||
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
|
||||
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
|
||||
# was eingestellt ist (Befund 30.08.2026).
|
||||
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
|
||||
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||
betrieb.frei_blaettern(werte))
|
||||
|
||||
# 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,
|
||||
orte_wurzeln=None) -> 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 []
|
||||
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||
from rippy import pfade
|
||||
|
||||
orte = [pfade.verbinden(roh, job_id)]
|
||||
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
|
||||
#
|
||||
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
|
||||
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
|
||||
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
|
||||
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
|
||||
#
|
||||
# "F:\\".rstrip("/\\") -> "F:"
|
||||
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
|
||||
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
|
||||
#
|
||||
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
|
||||
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
|
||||
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
|
||||
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
|
||||
wahl = (work_dir or "").strip()
|
||||
vergleich = wahl.rstrip("/\\")
|
||||
grenze = (medien or "").rstrip("/\\")
|
||||
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||
if vergleich and (frei or vergleich == grenze
|
||||
or vergleich.startswith(grenze + "/")):
|
||||
orte.append(pfade.verbinden(wahl, job_id))
|
||||
for name in media_unterordner or []:
|
||||
if name:
|
||||
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||
gesehen, eindeutig = set(), []
|
||||
for ort in orte:
|
||||
if ort not in gesehen:
|
||||
gesehen.add(ort)
|
||||
eindeutig.append(ort)
|
||||
return eindeutig
|
||||
|
||||
|
||||
def nativ_nachsehen() -> bool:
|
||||
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||
|
||||
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||
`pruefen`.
|
||||
"""
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
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"
|
||||
if laufen is None and nativ_nachsehen():
|
||||
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||
#
|
||||
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||
#
|
||||
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||
# 14:54:40 WindowsTerminal.exe
|
||||
#
|
||||
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||
# gehen ganz kurz auf und dann wieder zu."
|
||||
#
|
||||
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||
# unter Windows grundsaetzlich unsichtbar.
|
||||
#
|
||||
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||
try:
|
||||
return "da" if os.path.isdir(pfad) else "weg"
|
||||
except OSError:
|
||||
return "unklar"
|
||||
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,
|
||||
orte_wurzeln=None) -> 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.
|
||||
"""
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden = []
|
||||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||
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,
|
||||
orte_wurzeln=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
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden, unklar = [], False
|
||||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||
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:
|
||||
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||||
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||||
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||||
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||||
voll = _pfade.verbinden(pfad, name)
|
||||
try:
|
||||
if isfile(voll):
|
||||
bytes_gesamt += getsize(voll)
|
||||
dateien += 1
|
||||
except OSError:
|
||||
continue
|
||||
return bytes_gesamt, dateien
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
||||
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
||||
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
||||
wo auch die Ampel läuft.
|
||||
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
|
||||
|
||||
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
|
||||
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
|
||||
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
|
||||
Plattformen — nachgemessen: 57 Routen, sauberer Import.
|
||||
|
||||
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
|
||||
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
|
||||
Plattform greift, ist eine halbe Zusage — genau daran ist die Ampel am
|
||||
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
||||
import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
|
||||
|
||||
|
||||
def test_main_importierbar_und_routen_verdrahtet():
|
||||
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
|
||||
# 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",
|
||||
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
|
||||
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
|
||||
"/betrieb",
|
||||
):
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
@@ -157,3 +164,771 @@ def test_worker_setup_routen_die_gebraucht_werden_sind_da():
|
||||
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)
|
||||
|
||||
|
||||
# --- Live-Ereignisse (SSE), Etappe V2-3 --------------------------------------
|
||||
|
||||
|
||||
def test_events_route_ist_verdrahtet():
|
||||
"""Ohne diese Route fällt das UI stumm auf seinen letzten Stand zurück —
|
||||
und weil ein Abriss KEINE Aussage ist, sähe der Nutzer einfach nichts
|
||||
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
|
||||
from main import app
|
||||
|
||||
assert "/events" in {route.path for route in app.routes}
|
||||
|
||||
|
||||
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():
|
||||
"""`id:` ist nicht Kosmetik — der Browser schickt genau diesen Wert beim
|
||||
Wiederverbinden als Last-Event-ID zurück. Fehlt er, gibt es keine
|
||||
lückenlose Wiederaufnahme, und jeder WLAN-Wechsel reisst ein Loch."""
|
||||
from main import _sse_rahmen
|
||||
|
||||
rahmen = _sse_rahmen({"seq": 42, "typ": "job.progress", "daten": {"prozent": 7}})
|
||||
zeilen = rahmen.split("\n")
|
||||
assert zeilen[0] == "id: 42"
|
||||
assert zeilen[1] == "event: job.progress"
|
||||
assert zeilen[2].startswith("data: {")
|
||||
# Zwei Leerzeilen am Ende: eine schliesst das Ereignis, die zweite ist der
|
||||
# Trenner. Ohne den doppelten Umbruch haelt der Browser das Ereignis fuer
|
||||
# unvollstaendig und liefert es NIE aus.
|
||||
assert rahmen.endswith("\n\n")
|
||||
|
||||
|
||||
def test_sse_rahmen_uebersteht_umlaute():
|
||||
"""Job-Titel und Log-Zeilen sind deutsch. Mit ensure_ascii=True kaeme
|
||||
"Gr\u00f6\u00dfe" beim Nutzer an."""
|
||||
from main import _sse_rahmen
|
||||
|
||||
rahmen = _sse_rahmen({"seq": 1, "typ": "log.line", "daten": {"text": "Größe"}})
|
||||
assert "Größe" in rahmen
|
||||
|
||||
|
||||
def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
|
||||
"""„konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||
|
||||
Genau diese Vermischung hat in v1 die Job-Liste im Sekundentakt geleert
|
||||
(fuenfmal `catch(() => [])` im UI). Ein Snapshot mit devices=[] wuerde dem
|
||||
UI sagen „du hast kein Laufwerk"; None sagt „ich weiss es gerade nicht",
|
||||
und das UI behaelt seinen Stand.
|
||||
|
||||
OHNE DATENBANK: Die Ampel hat keine Postgres — der erste Anlauf dieses
|
||||
Tests lief deshalb rot (Lauf 170). Der Store wird hier ersetzt, denn
|
||||
geprueft wird die Snapshot-LOGIK, nicht die Datenbank.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.db, "list_workers", lambda: [])
|
||||
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||
|
||||
def kaputt():
|
||||
raise OSError("Laufwerk haengt")
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", kaputt)
|
||||
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
|
||||
assert zustand["devices"] is None, "Ein unlesbares Laufwerk darf nicht als [] durchgehen"
|
||||
assert zustand["jobs"] == []
|
||||
|
||||
|
||||
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
||||
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
|
||||
er den Rest raten und faellt auf Polling zurueck.
|
||||
|
||||
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
|
||||
|
||||
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
|
||||
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
|
||||
Viertelminute auf „Platz fuer Rippy: unbekannt" — und das sieht aus wie
|
||||
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
|
||||
Windows-Fenster gesehen.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
|
||||
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||
#
|
||||
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
|
||||
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||
assert "info" in zustand, (
|
||||
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
|
||||
"bis zu 15 Sekunden auf 'unbekannt'."
|
||||
)
|
||||
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
|
||||
assert zustand["workers"] == [{"name": "pc"}]
|
||||
|
||||
|
||||
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
|
||||
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
|
||||
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.db, "list_workers", lambda: [])
|
||||
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
|
||||
async def platzt():
|
||||
raise RuntimeError("Platte weg")
|
||||
|
||||
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||
|
||||
|
||||
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
||||
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
|
||||
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
|
||||
— auf einem PC ohne Container und ohne zweiten Worker.
|
||||
|
||||
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
|
||||
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
|
||||
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
|
||||
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
|
||||
# Lauf 170 rot geworden. /betrieb braucht keine.
|
||||
antwort = TestClient(app).get("/betrieb")
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["modus"] in ("standalone", "verteilt")
|
||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||
"container_pfade", "werkzeuge_verwalten",
|
||||
"frei_blaettern"}
|
||||
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
|
||||
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
|
||||
# der keinen Container hat (Befund 28.08.2026).
|
||||
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
|
||||
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||
if not daten["im_container"]:
|
||||
assert daten["hilfe_befehl"] == ""
|
||||
|
||||
|
||||
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
|
||||
#
|
||||
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
|
||||
#
|
||||
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||
#
|
||||
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
|
||||
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
|
||||
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
|
||||
# auch `running` auf `processing` ab.
|
||||
#
|
||||
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
|
||||
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
|
||||
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
|
||||
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
|
||||
|
||||
|
||||
def _echte_db_zeile():
|
||||
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"id": "j1",
|
||||
"disc_type": "bluray", # NICHT "type"
|
||||
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
|
||||
"finished_at": None,
|
||||
"status": "running", # wird zu "processing"
|
||||
"device": r"\.\G:",
|
||||
"progress": 12,
|
||||
"title": "Evangelion 2.22",
|
||||
"error": None,
|
||||
"meta": None,
|
||||
}
|
||||
|
||||
|
||||
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
|
||||
import main
|
||||
|
||||
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||
assert ui["type"] == "bluray"
|
||||
assert ui["startTime"].startswith("2026-08-29")
|
||||
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
|
||||
|
||||
|
||||
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
|
||||
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
|
||||
Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft."""
|
||||
import main
|
||||
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
|
||||
|
||||
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||
kurz = _job_kurz(ui)
|
||||
for feld in UI_PFLICHTFELDER:
|
||||
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
|
||||
assert kurz["type"] == "bluray"
|
||||
assert kurz["status"] == "processing"
|
||||
|
||||
|
||||
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
|
||||
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.startup_event)
|
||||
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
|
||||
assert "_job_row_to_model" in quelle
|
||||
|
||||
|
||||
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
|
||||
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
|
||||
|
||||
Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
|
||||
|
||||
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
|
||||
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
|
||||
(beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der
|
||||
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
|
||||
weiter oben stand.
|
||||
|
||||
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main)
|
||||
# Die eine erlaubte Fundstelle ist die Funktion selbst.
|
||||
stellen = quelle.count("device_discovery.device_info(")
|
||||
assert stellen == 1, (
|
||||
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
|
||||
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
|
||||
for name in ("_snapshot", "startup_event", "get_devices"):
|
||||
text = inspect.getsource(getattr(main, name))
|
||||
assert "device_info(" not in text, \
|
||||
"%s baut die Laufwerksliste selbst" % name
|
||||
|
||||
|
||||
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraete = main.laufwerke_mit_disc()
|
||||
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
|
||||
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||
|
||||
assert "disc" not in main.laufwerke_mit_disc()[0]
|
||||
|
||||
|
||||
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
|
||||
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
|
||||
sein."
|
||||
|
||||
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg —
|
||||
damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und
|
||||
einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
|
||||
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
|
||||
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
|
||||
(gemessen: Disc nach 119 s erkannt).
|
||||
"""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||
|
||||
geraet = main.laufwerke_mit_disc()[0]
|
||||
assert geraet["disc_wird_erkannt"] is True
|
||||
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
|
||||
assert "disc" not in geraet
|
||||
|
||||
|
||||
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraet = main.laufwerke_mit_disc()[0]
|
||||
assert not geraet.get("disc_wird_erkannt")
|
||||
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
|
||||
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
|
||||
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
|
||||
import main
|
||||
|
||||
assert "disc_wird_erkannt" in main.Device.model_fields
|
||||
|
||||
|
||||
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
|
||||
#
|
||||
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
|
||||
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
|
||||
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
|
||||
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
|
||||
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||
# sein soll.
|
||||
|
||||
|
||||
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||
"type": "bluray", "status": "ready"}])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||
|
||||
geraete = main.laufwerke_notdurft()
|
||||
assert geraete[0]["disc_wird_erkannt"] is True
|
||||
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
|
||||
|
||||
|
||||
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
|
||||
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
|
||||
„behalte deinen Stand", und das ist die ehrliche Antwort."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
assert main.laufwerke_notdurft() is None
|
||||
|
||||
|
||||
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
|
||||
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
|
||||
nur das Abfragen des Laufwerks ist teuer."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||
|
||||
geraete = main.laufwerke_notdurft()
|
||||
assert geraete[0]["disc_wird_erkannt"] is True
|
||||
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
|
||||
|
||||
|
||||
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
|
||||
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||
"type": "bluray", "status": "ready",
|
||||
"disc_wird_erkannt": True}])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraet = main.laufwerke_notdurft()[0]
|
||||
assert "disc_wird_erkannt" not in geraet
|
||||
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
|
||||
#
|
||||
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
|
||||
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
|
||||
# fenster ganz kurz im hintergrund."
|
||||
#
|
||||
# Beides war dieselbe Schleife: `except OSError: continue` liess
|
||||
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
|
||||
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
|
||||
# naechste Statusabfrage scheitert — und so weiter.
|
||||
|
||||
|
||||
def _CDS():
|
||||
import main
|
||||
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
|
||||
|
||||
|
||||
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(False, None, ok) == "erststart"
|
||||
|
||||
|
||||
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
|
||||
import main
|
||||
_, leer, _ = _CDS()
|
||||
assert main.disc_entscheidung(False, None, leer) == ""
|
||||
|
||||
|
||||
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
|
||||
"""Der Kern des Befundes."""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||
|
||||
|
||||
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
|
||||
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
|
||||
„noch nie gesehen" verwechselt, scannt endlos.
|
||||
|
||||
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
|
||||
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
|
||||
"""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
|
||||
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
|
||||
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
|
||||
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
|
||||
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
|
||||
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||
|
||||
|
||||
def test_auswurf_wird_erkannt():
|
||||
import main
|
||||
ok, leer, offen = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
|
||||
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
|
||||
|
||||
|
||||
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
|
||||
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
|
||||
weg, sobald das Laufwerk kurz beschaeftigt ist."""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, 3) == ""
|
||||
|
||||
|
||||
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
|
||||
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.disc_watcher)
|
||||
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
|
||||
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
|
||||
|
||||
|
||||
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
|
||||
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
|
||||
|
||||
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||
gemessen' aber messung wird nicht abgeschlossen."
|
||||
|
||||
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
|
||||
seine Jobs mit dem nackten `_job_row_to_model` — also ohne. Seit V2-3
|
||||
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
|
||||
also brav berechnet und niemandem gezeigt.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main._snapshot)
|
||||
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
|
||||
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
|
||||
|
||||
|
||||
def test_die_jobliste_traegt_die_restzeit_felder():
|
||||
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
|
||||
import main
|
||||
|
||||
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
|
||||
assert feld in main.Job.model_fields, feld
|
||||
|
||||
|
||||
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
|
||||
#
|
||||
# Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
|
||||
# bereits bei 30% und er liest immernoch."
|
||||
#
|
||||
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
|
||||
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
|
||||
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
|
||||
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
|
||||
# wissen wir — der Job laeuft ja auf ihr.
|
||||
|
||||
|
||||
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
gescannt = []
|
||||
|
||||
class NieBenutzt:
|
||||
def scan(self, pfad):
|
||||
gescannt.append(pfad)
|
||||
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
|
||||
|
||||
monkeypatch.setattr(main, "PreScan", NieBenutzt)
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||
assert gescannt == []
|
||||
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
|
||||
|
||||
|
||||
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
class Ergebnis:
|
||||
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
|
||||
|
||||
def to_dict(self):
|
||||
return {"title": "Akira"}
|
||||
|
||||
class Scanner:
|
||||
def scan(self, pfad):
|
||||
return Ergebnis()
|
||||
|
||||
monkeypatch.setattr(main, "PreScan", Scanner)
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
|
||||
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
|
||||
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
|
||||
lambda p: asyncio.sleep(0))
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
|
||||
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
|
||||
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
|
||||
Ende stehen — der Scan davor haelt sie ja fest."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
|
||||
if not quelle:
|
||||
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
|
||||
quelle = inspect.getsource(main)
|
||||
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
|
||||
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
|
||||
|
||||
|
||||
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
|
||||
#
|
||||
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
|
||||
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
|
||||
#
|
||||
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
|
||||
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
|
||||
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
|
||||
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
|
||||
|
||||
|
||||
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
gestartet = []
|
||||
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
|
||||
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
|
||||
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
|
||||
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
|
||||
|
||||
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
|
||||
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
|
||||
|
||||
|
||||
def test_job_offen_zaehlt_die_kompression_mit():
|
||||
from rippy.store import JOB_OFFEN
|
||||
|
||||
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
|
||||
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
|
||||
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
|
||||
|
||||
|
||||
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
||||
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
|
||||
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
|
||||
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
|
||||
import inspect
|
||||
|
||||
from rippy import store
|
||||
|
||||
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
||||
|
||||
|
||||
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
|
||||
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
|
||||
|
||||
12:49:52 bluray-Rip gestartet
|
||||
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||
12:50:12 makemkvcon endete mit Code 11
|
||||
|
||||
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
|
||||
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
|
||||
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
|
||||
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
|
||||
"""
|
||||
import main
|
||||
|
||||
gefragt = []
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: ["/dev/sr0"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||
|
||||
stand = main.laufwerke_mit_disc()
|
||||
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
|
||||
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
|
||||
|
||||
|
||||
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
|
||||
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
|
||||
import main
|
||||
|
||||
gefragt = []
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: ["/dev/sr0"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||
|
||||
main.laufwerke_mit_disc()
|
||||
assert gefragt == ["/dev/sr0"]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen.
|
||||
|
||||
## Warum es diese Tests gibt (Befund 28.08.2026)
|
||||
|
||||
Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine
|
||||
Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar:
|
||||
|
||||
**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und
|
||||
`clients/thetvdb.py` machten im Konstruktor `from db import get_settings`.
|
||||
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
|
||||
mehr — main.py schreibt seitdem `from rippy import store as db`, diese drei
|
||||
Module haben es nie mitbekommen. Damit starb JEDE Metadaten-Abfrage schon
|
||||
beim Erzeugen des Clients mit `ModuleNotFoundError`, auf Windows UND auf der
|
||||
VM. `_auto_prescan` verschluckte es in seinem `except Exception`.
|
||||
|
||||
**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an — den
|
||||
Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein
|
||||
Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan
|
||||
mit.
|
||||
|
||||
Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht.
|
||||
Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen
|
||||
den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_tmdb_client_baut_sich_ohne_datenbank():
|
||||
"""Der Test, der den Phantom-Import gefangen haette."""
|
||||
from clients.tmdb import TMDBClient
|
||||
|
||||
klient = TMDBClient()
|
||||
assert hasattr(klient, "api_key")
|
||||
|
||||
|
||||
def test_omdb_client_baut_sich_ohne_datenbank():
|
||||
from clients.omdb import OMDbClient
|
||||
|
||||
assert OMDbClient() is not None
|
||||
|
||||
|
||||
def test_thetvdb_client_baut_sich_ohne_datenbank():
|
||||
from clients.thetvdb import TheTVDBClient
|
||||
|
||||
assert TheTVDBClient() is not None
|
||||
|
||||
|
||||
def test_kein_client_importiert_das_verschwundene_db_modul():
|
||||
"""Der Waechter gegen genau diesen Fehler, mechanisch.
|
||||
|
||||
Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc
|
||||
einlegt — und dann als „keine Metadaten", nicht als Import-Fehler.
|
||||
"""
|
||||
import os
|
||||
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
fundstellen = []
|
||||
for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")):
|
||||
ordner[:] = [o for o in ordner if o != "__pycache__"]
|
||||
for name in dateien:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
pfad = os.path.join(wurzel, name)
|
||||
with open(pfad, encoding="utf-8") as f:
|
||||
for nr, zeile in enumerate(f, 1):
|
||||
if zeile.strip().startswith(("from db import", "import db")):
|
||||
fundstellen.append("%s:%d" % (name, nr))
|
||||
assert not fundstellen, (
|
||||
"docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt "
|
||||
"rippy.store. Fundstellen: " + ", ".join(fundstellen))
|
||||
|
||||
|
||||
# ── Der Zwischenspeicher ────────────────────────────────────────────────
|
||||
def test_cache_ohne_redis_liefert_None_statt_zu_werfen():
|
||||
"""Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.
|
||||
|
||||
Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den
|
||||
Pre-Scan mit — und die Disc blieb namenlos.
|
||||
"""
|
||||
from cache import cache
|
||||
|
||||
class ToterRedis:
|
||||
def get(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def set(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def setex(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def delete(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def flushdb(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def ping(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
echt = cache.redis_client
|
||||
cache.redis_client = ToterRedis()
|
||||
try:
|
||||
assert cache.get("egal") is None
|
||||
assert cache.set("egal", {"a": 1}) is False
|
||||
assert cache.delete("egal") is False
|
||||
assert cache.clear() is False
|
||||
cache.init_cache() # darf ebenfalls nicht werfen
|
||||
assert cache.zustand()["erreichbar"] is False
|
||||
finally:
|
||||
cache.redis_client = echt
|
||||
|
||||
|
||||
def test_cache_meldet_den_ausfall_nur_EINMAL(capsys):
|
||||
"""Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf
|
||||
kaeme sie mehrfach."""
|
||||
from cache import cache
|
||||
|
||||
class ToterRedis:
|
||||
def get(self, *a, **k):
|
||||
raise cache.RedisError("weg")
|
||||
|
||||
echt, zustand = cache.redis_client, cache._erreichbar
|
||||
cache.redis_client = ToterRedis()
|
||||
cache._erreichbar = None
|
||||
try:
|
||||
for _ in range(5):
|
||||
cache.get("x")
|
||||
ausgabe = capsys.readouterr().out
|
||||
assert ausgabe.count("nicht erreichbar") == 1, ausgabe
|
||||
finally:
|
||||
cache.redis_client, cache._erreichbar = echt, zustand
|
||||
|
||||
|
||||
def test_ein_kaputter_eintrag_wirft_nicht():
|
||||
"""Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die
|
||||
Antwort eben frisch geholt."""
|
||||
from cache import cache
|
||||
|
||||
class KaputterRedis:
|
||||
def get(self, *a, **k):
|
||||
return "{kein json"
|
||||
|
||||
echt = cache.redis_client
|
||||
cache.redis_client = KaputterRedis()
|
||||
try:
|
||||
assert cache.get("x") is None
|
||||
finally:
|
||||
cache.redis_client = echt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"])
|
||||
def test_jeder_client_ist_importierbar(name):
|
||||
"""Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand
|
||||
eine Disc einlegt."""
|
||||
__import__("clients." + name)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
|
||||
#
|
||||
# Commander 29.08.2026: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||
#
|
||||
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
|
||||
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
|
||||
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
|
||||
# Punkt, und `restzeit_sekunden` braucht zwei.
|
||||
|
||||
|
||||
def _ohne_cache():
|
||||
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
|
||||
return (lambda k: None), (lambda k, v, expire=None: False)
|
||||
|
||||
|
||||
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
|
||||
"""DER Fall des Commanders."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
|
||||
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
|
||||
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
|
||||
|
||||
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
|
||||
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
|
||||
|
||||
|
||||
def test_der_cache_hat_weiterhin_vorrang():
|
||||
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
|
||||
API-Neustart, das Woerterbuch im Prozess nicht."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
|
||||
ergebnis = eta.aktualisiere_und_schaetze(
|
||||
"j2", "running", 25, 620.0,
|
||||
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
|
||||
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
|
||||
assert ergebnis["sekunden"] > 0
|
||||
|
||||
|
||||
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
|
||||
"""Rip und Kompression haben nichts miteinander zu tun."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
|
||||
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
|
||||
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
|
||||
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
|
||||
|
||||
|
||||
def test_alte_reihen_wachsen_nicht_unbegrenzt():
|
||||
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
|
||||
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
|
||||
eta.aktualisiere_und_schaetze("neu", "running", 10,
|
||||
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
|
||||
assert eta.schluessel("alt") not in eta._REIHEN
|
||||
assert eta.schluessel("neu") in eta._REIHEN
|
||||
@@ -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
|
||||
@@ -1,118 +0,0 @@
|
||||
"""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("", "") == ""
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||
import pytest
|
||||
|
||||
from makemkv_key import extract_key
|
||||
|
||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
|
||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||
assert treffer == _KEY
|
||||
assert treffer.startswith("T-")
|
||||
|
||||
|
||||
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
|
||||
#
|
||||
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
|
||||
# > ausgelesen werden, ich glaube das web rippy kann das"
|
||||
#
|
||||
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
|
||||
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
|
||||
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
|
||||
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
|
||||
|
||||
@pytest.fixture
|
||||
def klient():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
|
||||
import main as api
|
||||
|
||||
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
|
||||
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||
|
||||
antwort = klient.post("/system/makemkv-key/holen")
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["geholt"] is True
|
||||
assert daten["geaendert"] is True
|
||||
assert daten["endet_auf"] == _KEY[-6:]
|
||||
|
||||
|
||||
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
|
||||
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
|
||||
ein zweiter Weg, ihn zu verlieren."""
|
||||
import main as api
|
||||
|
||||
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
|
||||
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||
|
||||
text = klient.post("/system/makemkv-key/holen").text
|
||||
assert _KEY not in text
|
||||
assert _KEY[:20] not in text
|
||||
|
||||
|
||||
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
|
||||
monkeypatch):
|
||||
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
|
||||
def wirft(*a, **k):
|
||||
raise OSError("Verbindung abgelehnt")
|
||||
|
||||
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
|
||||
antwort = klient.post("/system/makemkv-key/holen")
|
||||
assert antwort.status_code == 502
|
||||
assert "Forum" in antwort.json()["detail"]
|
||||
|
||||
|
||||
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
|
||||
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
|
||||
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
|
||||
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
|
||||
antwort = klient.post("/system/makemkv-key/holen")
|
||||
assert antwort.status_code == 502
|
||||
assert "geändert" in antwort.json()["detail"]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||
|
||||
from mounts import uebersetze_smb_fehler, validiere_name
|
||||
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():
|
||||
@@ -69,3 +75,351 @@ def test_mounten_loest_stale_vor_dem_mount():
|
||||
|
||||
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(
|
||||
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
|
||||
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
|
||||
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
|
||||
# gruen, ohne etwas zu beweisen.
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "ablauf.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,82 @@
|
||||
"""Welche Pfade darf die API anfassen — und wo ist „oben"?
|
||||
|
||||
## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026)
|
||||
|
||||
`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container
|
||||
ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und
|
||||
die Folge war eine Oberflaeche, die stillschweigend nichts konnte:
|
||||
|
||||
* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht),
|
||||
* `/browse` antwortete auf JEDEN Pfad mit 422,
|
||||
* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag.
|
||||
|
||||
Sein Befund dazu: „Wäre es möglich das Arbeitsverzeichnis zu ändern?
|
||||
momentan geht das nicht."
|
||||
|
||||
Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im
|
||||
Netz — eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein
|
||||
Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem
|
||||
Rechner sitzt. Diese Tests halten beide Haelften fest.
|
||||
"""
|
||||
|
||||
import main
|
||||
|
||||
|
||||
# ── Die Grenze gilt, wo zugehoert wird ──────────────────────────────────
|
||||
def test_im_container_gilt_die_wurzel_weiter():
|
||||
"""Der Docker-Weg darf sich durch die Reparatur NICHT lockern."""
|
||||
assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False)
|
||||
assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False)
|
||||
# Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb.
|
||||
assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False)
|
||||
|
||||
|
||||
def test_nativ_ist_auch_eine_freigabe_erlaubt():
|
||||
"""Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar
|
||||
keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar."""
|
||||
assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True)
|
||||
assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True)
|
||||
|
||||
|
||||
def test_ein_leerer_pfad_ist_nie_erlaubt():
|
||||
"""Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`."""
|
||||
assert not main.pfad_erlaubt("", "/app/media", frei=True)
|
||||
assert not main.pfad_erlaubt("", "/app/media", frei=False)
|
||||
|
||||
|
||||
# ── Wo ist oben ─────────────────────────────────────────────────────────
|
||||
def test_an_der_wurzel_ist_schluss():
|
||||
"""Im Container. `None` heisst: kein Knopf „nach oben"."""
|
||||
assert main.eltern_von("/app/media", "/app/media", frei=False) is None
|
||||
assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media"
|
||||
|
||||
|
||||
def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste():
|
||||
"""Zwei Fallen auf einmal.
|
||||
|
||||
`os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` — ein Knopf „nach oben",
|
||||
der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der
|
||||
Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke:
|
||||
Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer
|
||||
100 GB Rohdaten.
|
||||
"""
|
||||
import ntpath
|
||||
import os
|
||||
|
||||
echt = os.path.dirname
|
||||
os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux
|
||||
try:
|
||||
assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == ""
|
||||
assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users"
|
||||
finally:
|
||||
os.path.dirname = echt
|
||||
|
||||
|
||||
def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
||||
"""Eine leere Wurzel wuerde jede Pruefung durchwinken."""
|
||||
assert main.medien_wurzel()
|
||||
|
||||
|
||||
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
|
||||
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
|
||||
assert isinstance(main.betrieb_laufwerke(), list)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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 = (
|
||||
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
|
||||
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
|
||||
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
|
||||
# gruen, ohne etwas zu beweisen.
|
||||
pathlib.Path(__file__).resolve().parents[1] / "worker" / "ablauf.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
|
||||
@@ -47,3 +47,177 @@ 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"
|
||||
|
||||
|
||||
# ── Was auf FREMDEN Rechnern gilt (Befund 28.08.2026) ───────────────────
|
||||
#
|
||||
# Der Commander: "du hast jetzt nur mein laufwerk analysiert oder? wenn ich
|
||||
# rippy weitergebe wie laeuft es dort?"
|
||||
#
|
||||
# Berechtigt. Gemessen wurde an EINEM Laufwerk (LG BU40N) und EINER Disc
|
||||
# (BD-50 mit BDMV/META). Diese Tests halten fest, was bei ANDEREN Discs
|
||||
# passieren muss -- ohne dass eine davon vorhanden sein muss.
|
||||
def test_bluray_ohne_metadaten_gibt_ehrlich_nichts_zurueck(tmp_path):
|
||||
"""Sehr viele Blu-rays haben kein BDMV/META. Dann greift der Rueckfall
|
||||
auf das Volume-Label -- aber diese Funktion darf nichts erfinden."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
(tmp_path / "BDMV" / "STREAM").mkdir(parents=True)
|
||||
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_dvd_hat_kein_bdmv(tmp_path):
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
(tmp_path / "VIDEO_TS").mkdir()
|
||||
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_bdmt_wird_gelesen_wenn_da(tmp_path):
|
||||
"""Der Weg, der bei der Disc des Commanders 'Evangelion 2.22' lieferte —
|
||||
hier ohne Disc nachgestellt."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||
meta.mkdir(parents=True)
|
||||
(meta / "bdmt_deu.xml").write_text(
|
||||
'<?xml version="1.0"?><disclib><di:discinfo>'
|
||||
'<di:title><di:name>Evangelion 2.22</di:name></di:title>'
|
||||
'</di:discinfo></disclib>', encoding="utf-8")
|
||||
assert titel_aus_bdmt(str(tmp_path)) == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_englisch_wird_bevorzugt(tmp_path):
|
||||
"""Die APIs suchen mit englischen Titeln besser."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||
meta.mkdir(parents=True)
|
||||
for datei, name in (("bdmt_deu.xml", "Deutscher Titel"),
|
||||
("bdmt_eng.xml", "English Title")):
|
||||
(meta / datei).write_text(
|
||||
"<x><di:name>%s</di:name></x>" % name, encoding="utf-8")
|
||||
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||
|
||||
|
||||
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||
Programme liegen in „Programme", nicht im PATH) und war unter Docker
|
||||
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
|
||||
der Aufruf noch die shutil.which-Suche zurueckkommen.
|
||||
|
||||
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden` —
|
||||
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
|
||||
die Entfernung deshalb erst dort gemeldet.)"""
|
||||
import os
|
||||
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(hier, "prescan", "prescan.py"), encoding="utf-8") as f:
|
||||
# NUR Code, keine Kommentare: Der erste Anlauf dieses Tests fiel ueber
|
||||
# den eigenen Erklaertext, in dem der alte Aufruf zitiert wird. Ein
|
||||
# Waechter, der an einer Erklaerung scheitert, prueft das Falsche.
|
||||
code = [z for z in f if not z.strip().startswith("#")]
|
||||
quelle = "".join(code)
|
||||
assert "shutil.which(" not in quelle, (
|
||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||
assert "makemkvcon" not in quelle, (
|
||||
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
> „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
|
||||
> cover."
|
||||
|
||||
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
|
||||
sondern eine Bedingung im Pre-Scan:
|
||||
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
|
||||
An seiner Disc gemessen:
|
||||
|
||||
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||
'Evangelion' 20 Treffer irgendein Evangelion
|
||||
|
||||
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
|
||||
Poster — und wurde verworfen, weil der Titel nicht woertlich gleich war.
|
||||
|
||||
## Warum die Trefferzahl und nicht die Aehnlichkeit
|
||||
|
||||
„Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
|
||||
Aehnlichkeit — das steht als Messung schon laenger im Code und faellt durch
|
||||
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
|
||||
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
|
||||
weiss was er sucht. Wer 20 bekommt, hat geraten.
|
||||
|
||||
Diese Tests spritzen die Clients ein — kein Netz, keine Schluessel, keine
|
||||
Disc.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeTMDB:
|
||||
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
|
||||
|
||||
def __init__(self, treffer_je_suche, details=None):
|
||||
self.treffer = treffer_je_suche
|
||||
self.details = details or {}
|
||||
self.gefragt = []
|
||||
|
||||
def search_movie(self, begriff):
|
||||
self.gefragt.append(begriff)
|
||||
return self.treffer.get(begriff, [])
|
||||
|
||||
def search_tv(self, begriff):
|
||||
return []
|
||||
|
||||
def get_movie_details(self, kennung):
|
||||
return self.details.get(kennung)
|
||||
|
||||
def get_tv_details(self, kennung):
|
||||
return None
|
||||
|
||||
def find_by_imdb(self, kennung):
|
||||
return None
|
||||
|
||||
|
||||
class StummerClient:
|
||||
def lookup(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
DETAILS_2_0 = {
|
||||
22843: {
|
||||
"title": "Evangelion: 2.0 You Can (Not) Advance",
|
||||
"release_date": "2009-06-27",
|
||||
"overview": "Der Pilotin Mari gelingt es …",
|
||||
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
|
||||
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
|
||||
"runtime": 112,
|
||||
"genres": [{"name": "Animation"}],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _prescan(tmdb):
|
||||
from prescan.prescan import PreScan
|
||||
|
||||
p = PreScan()
|
||||
p.tmdb = tmdb
|
||||
p.jikan = StummerClient()
|
||||
p.omdb = StummerClient()
|
||||
return p
|
||||
|
||||
|
||||
def _toc(titel):
|
||||
return {"title": titel, "year": None, "tracks": [], "duration": 0,
|
||||
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
|
||||
|
||||
|
||||
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
|
||||
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
|
||||
tmdb = FakeTMDB(
|
||||
{"Evangelion 2.22": [{"id": 22843,
|
||||
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
|
||||
DETAILS_2_0)
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||
|
||||
assert ergebnis.confidence == 0.8
|
||||
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
|
||||
assert ergebnis.year == 2009
|
||||
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
|
||||
assert ergebnis.metadata["source"] == "tmdb"
|
||||
|
||||
|
||||
def test_woertlich_gleicher_titel_bleibt_sicherer():
|
||||
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
|
||||
tmdb = FakeTMDB(
|
||||
{"Logan": [{"id": 22843, "title": "Logan"}]},
|
||||
{22843: dict(DETAILS_2_0[22843], title="Logan")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
|
||||
assert ergebnis.confidence == 0.95
|
||||
|
||||
|
||||
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
|
||||
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
|
||||
werden. Es bleibt beim ehrlichen Vorschlag."""
|
||||
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
|
||||
tmdb = FakeTMDB({"Evangelion": viele},
|
||||
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
|
||||
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
|
||||
|
||||
|
||||
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
|
||||
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
|
||||
|
||||
Sonst wuerde „Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
|
||||
„Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
|
||||
"""
|
||||
tmdb = FakeTMDB(
|
||||
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
|
||||
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
|
||||
assert ergebnis.confidence == 0.6
|
||||
|
||||
|
||||
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
|
||||
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
|
||||
assert ergebnis.confidence == 0.3
|
||||
assert ergebnis.metadata["type"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
|
||||
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
|
||||
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
|
||||
desselben Films, aber eng genug, um Raten auszuschliessen."""
|
||||
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
|
||||
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
|
||||
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
|
||||
assert ergebnis.confidence == erwartet
|
||||
@@ -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,306 @@
|
||||
"""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}"
|
||||
|
||||
|
||||
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||
# prompt sieben Tests rot geworden).
|
||||
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||
|
||||
|
||||
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,
|
||||
orte_wurzeln=CONTAINER)
|
||||
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
|
||||
|
||||
|
||||
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||
#: Gegenstück zu CONTAINER weiter oben.
|
||||
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||
|
||||
|
||||
def test_laufwerks_wurzel_bleibt_absolut():
|
||||
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||
|
||||
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||
beschädigten Datenträger für etwas, das schon dalag.
|
||||
"""
|
||||
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||
assert "F:" + chr(92) + JOB in orte
|
||||
assert "F:" + JOB not in orte
|
||||
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||
|
||||
|
||||
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||
nicht am Trenner scheitern."""
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||
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, orte_wurzeln=CONTAINER)
|
||||
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, orte_wurzeln=CONTAINER)
|
||||
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),
|
||||
orte_wurzeln=CONTAINER)
|
||||
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, orte_wurzeln=CONTAINER)
|
||||
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, orte_wurzeln=CONTAINER)
|
||||
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, orte_wurzeln=CONTAINER)
|
||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||
assert e["unklar"] is True
|
||||
|
||||
|
||||
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
|
||||
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||
|
||||
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
|
||||
14:54:40 WindowsTerminal.exe
|
||||
|
||||
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
|
||||
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
|
||||
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
|
||||
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
|
||||
die Antwort weg fuer JEDES Verzeichnis.
|
||||
"""
|
||||
gestartet = []
|
||||
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
|
||||
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||
lambda *a, **k: gestartet.append(a))
|
||||
assert rohdaten.pruefen(str(tmp_path)) == "da"
|
||||
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
|
||||
assert gestartet == [], "es darf kein Prozess gestartet werden"
|
||||
|
||||
|
||||
def test_im_container_bleibt_der_kindprozess(monkeypatch):
|
||||
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
|
||||
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
|
||||
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
|
||||
aufrufe = []
|
||||
|
||||
class Antwort:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
|
||||
assert rohdaten.pruefen("/app/media/x") == "da"
|
||||
assert aufrufe[0][0] == "timeout"
|
||||
assert aufrufe[0][-1] == "/app/media/x"
|
||||
@@ -14,6 +14,15 @@ 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;
|
||||
|
||||
+124
-13
@@ -6,19 +6,91 @@ import LogsPage from './pages/Logs'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||
import { BetriebProvider } from './lib/useBetrieb'
|
||||
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
export default function App() {
|
||||
/**
|
||||
* Zeigt, ob der Live-Strom steht.
|
||||
*
|
||||
* Hier stand bis V2-3 ein fest verdrahtetes „ONLINE" mit pulsierendem Punkt —
|
||||
* es leuchtete auch dann gruen, wenn die API tot war. Genau die Sorte
|
||||
* Placebo-Anzeige, die in Etappe 19 schon einmal aufgeraeumt wurde
|
||||
* („Fortschritt log, Auswurf tat nichts, Encoder wurden behauptet statt
|
||||
* gemessen"). Jetzt sagt sie die Wahrheit.
|
||||
*/
|
||||
function VerbindungsAnzeige() {
|
||||
const { verbunden, zuletzt } = useStrom()
|
||||
if (verbunden) {
|
||||
return (
|
||||
<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>LIVE</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs font-mono text-amber-400 bg-amber-500/10 px-3 py-1.5 rounded-xl border border-amber-500/30"
|
||||
title={
|
||||
zuletzt
|
||||
? `Letzte Meldung um ${zuletzt.toLocaleTimeString()}. Die Anzeige zeigt diesen Stand, bis die Verbindung zurueck ist.`
|
||||
: 'Noch keine Verbindung zum Server.'
|
||||
}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>VERBINDUNG WEG</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppInhalt() {
|
||||
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)
|
||||
|
||||
/*
|
||||
* ⚠️ Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung.
|
||||
*
|
||||
* Hier stand `.catch(() => setSetupDone(true))` mit der Begründung
|
||||
* „API/Setup nicht erreichbar → nicht blockieren". Die Folge hat der
|
||||
* Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation
|
||||
* fehlte der Ersteinrichtungs-Assistent.**
|
||||
*
|
||||
* Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf,
|
||||
* der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort — der
|
||||
* erste Abruf scheitert also fast immer. Aus „ich konnte nicht nachsehen"
|
||||
* wurde „ist schon erledigt", und der Assistent war für immer weg.
|
||||
*
|
||||
* Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues
|
||||
* weiß, behält, was er wusste — und behauptet nichts.
|
||||
*
|
||||
* Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort
|
||||
* entscheidet.
|
||||
*/
|
||||
useEffect(() => {
|
||||
api.get('/setup')
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
let abgemeldet = false
|
||||
let versuche = 0
|
||||
|
||||
const fragen = () => {
|
||||
api.get('/setup')
|
||||
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
|
||||
.catch(() => {
|
||||
if (abgemeldet) return
|
||||
versuche += 1
|
||||
// Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis
|
||||
// dahin nicht, hat der Nutzer ein anderes Problem als den
|
||||
// Assistenten — dann zeigen wir die Oberfläche, damit er die Logs
|
||||
// sehen kann.
|
||||
if (versuche < 60) setTimeout(fragen, 2000)
|
||||
else setSetupDone(true)
|
||||
})
|
||||
}
|
||||
fragen()
|
||||
return () => { abgemeldet = true }
|
||||
}, [])
|
||||
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
@@ -28,7 +100,22 @@ export default function App() {
|
||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||
// Solange /setup noch nicht geantwortet hat: sagen, dass gewartet wird.
|
||||
// Ein leeres Fenster sieht aus wie ein Absturz — und nach einer frischen
|
||||
// Installation ist genau das der erste Eindruck.
|
||||
if (setupDone === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0b0f19]">
|
||||
<div className="text-center">
|
||||
<div className="text-amber-400 text-3xl mb-3">◉</div>
|
||||
<p className="text-slate-300 text-sm">Rippy startet …</p>
|
||||
<p className="text-slate-500 text-xs mt-1 font-mono">
|
||||
warte auf den Dienst
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||
|
||||
return (
|
||||
@@ -69,20 +156,44 @@ export default function App() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<VerbindungsAnzeige />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Viewport */}
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||
Bildschirm leer — samt Kopfzeile. */}
|
||||
<Fehlergrenze bereich={
|
||||
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</Fehlergrenze>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
|
||||
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das
|
||||
// alte Problem in neuer Form.
|
||||
// BetriebProvider ganz aussen: Was Rippy anzeigen DARF, entscheidet sich
|
||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||
return (
|
||||
<Fehlergrenze>
|
||||
<BetriebProvider>
|
||||
<EventStreamProvider>
|
||||
<AppInhalt />
|
||||
</EventStreamProvider>
|
||||
</BetriebProvider>
|
||||
</Fehlergrenze>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -8,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'
|
||||
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
extra,
|
||||
confirmText = 'Bestätigen',
|
||||
cancelText = 'Abbrechen',
|
||||
type = 'info',
|
||||
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{message}
|
||||
</p>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from './ui/Button'
|
||||
import { TypeBadge } from './ui/Badge'
|
||||
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||
import MetadataKorrektur from './MetadataKorrektur'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
|
||||
interface DiscInfo {
|
||||
title: string
|
||||
@@ -35,6 +36,8 @@ interface Device {
|
||||
serial?: string
|
||||
model?: string
|
||||
disc?: DiscInfo
|
||||
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
|
||||
disc_wird_erkannt?: boolean
|
||||
}
|
||||
|
||||
function posterUrl(disc?: DiscInfo): string | null {
|
||||
@@ -63,6 +66,7 @@ export default function DeviceDiscovery() {
|
||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||
const { toast } = useToast()
|
||||
const strom = useStrom()
|
||||
|
||||
const refreshDevices = async (manuell = false) => {
|
||||
if (manuell) setRefreshing(true)
|
||||
@@ -91,6 +95,10 @@ export default function DeviceDiscovery() {
|
||||
...(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"')
|
||||
@@ -120,11 +128,20 @@ export default function DeviceDiscovery() {
|
||||
}
|
||||
}
|
||||
|
||||
// Vorher: alle 5 s ein eigener Abruf von /devices (12 Anfragen pro Minute).
|
||||
// Jetzt liefert der Server die Aenderung, sobald sie passiert.
|
||||
//
|
||||
// WICHTIG — nur uebernehmen, wenn der Server wirklich etwas WEISS:
|
||||
// `strom.devices === null` heisst „konnte nicht nachsehen" (haengendes
|
||||
// Laufwerk, Zeitgrenze) und NICHT „es gibt keine Laufwerke". In dem Fall
|
||||
// bleibt der letzte bekannte Stand stehen. Genau diese Vermischung hat in
|
||||
// v1 die Listen im Sekundentakt geleert.
|
||||
useEffect(() => {
|
||||
refreshDevices()
|
||||
const interval = setInterval(refreshDevices, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
if (strom.devices !== null) {
|
||||
setDevices(strom.devices as Device[])
|
||||
setLoading(false)
|
||||
}
|
||||
}, [strom.devices])
|
||||
|
||||
const QUELLEN_LABEL: Record<string, string> = {
|
||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||
@@ -164,6 +181,34 @@ export default function DeviceDiscovery() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/*
|
||||
Die Erkennung LÄUFT NOCH — das muss man sehen.
|
||||
|
||||
Commander 29.08.2026: „das die disc erkennung noch läuft muss
|
||||
sichtbar sein". An einer Blu-ray dauert sie rund zwei Minuten
|
||||
(`makemkvcon info` läuft in seine 120-Sekunden-Grenze; gemessen:
|
||||
Disc nach 119 s erkannt). Vorher war in dieser Zeit NICHTS zu
|
||||
sehen — weder die Disc noch ein Hinweis. Das sah aus wie ein
|
||||
leeres Laufwerk, also wie ein Fehler.
|
||||
*/}
|
||||
{devices.filter(d => d.disc_wird_erkannt).map(device => (
|
||||
<div
|
||||
key={`erkennung-${device.id}`}
|
||||
className="mb-6 rounded-2xl border border-slate-700 bg-slate-900/60 px-5 py-4 flex items-center gap-3"
|
||||
>
|
||||
<RefreshCw size={18} className="animate-spin text-amber-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">
|
||||
Disc wird gelesen — {device.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis
|
||||
zu zwei Minuten; danach steht hier, was drin ist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Eine Fehlergrenze — damit ein Fehler nicht die ganze Oberfläche verschluckt.
|
||||
*
|
||||
* ## Der Befund des Commanders (29.08.2026)
|
||||
*
|
||||
* > „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||
* > auch keine fehlermeldung."
|
||||
*
|
||||
* Nachgestellt und in der Browser-Konsole gemessen:
|
||||
*
|
||||
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||
*
|
||||
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
|
||||
* 12 %). Nur zeigte die Oberfläche das nie — sie war weg. Grund: React baut
|
||||
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
|
||||
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
|
||||
* einzige Fehlergrenze.
|
||||
*
|
||||
* ## Warum die eigentliche Reparatur woanders sitzt
|
||||
*
|
||||
* Die Ursache war ein Ereignis, das den halben Job schickte
|
||||
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
|
||||
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
|
||||
*
|
||||
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
|
||||
* mitnehmen — und schon gar nicht schweigend. Der nächste Fehler dieser Art
|
||||
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
|
||||
* Oberfläche bleibt bedienbar.
|
||||
*
|
||||
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
|
||||
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
|
||||
*/
|
||||
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
/** Was hier kaputtgehen kann — steht in der Meldung. */
|
||||
bereich?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
fehler: Error | null
|
||||
}
|
||||
|
||||
export class Fehlergrenze extends Component<Props, State> {
|
||||
state: State = { fehler: null }
|
||||
|
||||
static getDerivedStateFromError(fehler: Error): State {
|
||||
return { fehler }
|
||||
}
|
||||
|
||||
componentDidCatch(fehler: Error, info: ErrorInfo) {
|
||||
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
|
||||
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
|
||||
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
|
||||
fehler, info.componentStack)
|
||||
}
|
||||
|
||||
neuLaden = () => {
|
||||
this.setState({ fehler: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.fehler) return this.props.children
|
||||
|
||||
return (
|
||||
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
|
||||
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
|
||||
{this.props.bereich
|
||||
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
|
||||
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
|
||||
</h2>
|
||||
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
|
||||
Der Fehler betrifft nur die Anzeige.{' '}
|
||||
<strong>Laufende Rips gehen weiter</strong> — Rippy arbeitet im
|
||||
Hintergrund, auch wenn dieser Bereich leer bleibt.
|
||||
</p>
|
||||
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
|
||||
{this.state.fehler.message || String(this.state.fehler)}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={this.neuLaden}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
|
||||
>
|
||||
Nochmal versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
Seite neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -37,35 +38,48 @@ const ENCODER_LABELS: Record<string, string> = {
|
||||
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||||
const PLATZ_WARNUNG_GB = 60
|
||||
|
||||
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
|
||||
function presetsFuer(schwach: boolean) {
|
||||
if (schwach) {
|
||||
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
|
||||
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
|
||||
// als H.265, dafür etwas größer).
|
||||
return {
|
||||
transcodePreset: 'HQ 1080p30 Surround',
|
||||
transcodePresetBluray: 'HQ 1080p30 Surround',
|
||||
transcodePresetDvd: 'HQ 576p25 Surround',
|
||||
transcodePresetUhd: PRESET_KEINE,
|
||||
}
|
||||
// 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 {
|
||||
transcodePreset: 'H.265 MKV 1080p30',
|
||||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||||
transcodePresetDvd: 'H.265 MKV 576p25',
|
||||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||||
...(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 betrieb = useBetrieb()
|
||||
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)
|
||||
@@ -77,15 +91,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
useEffect(() => {
|
||||
let laeuft = true
|
||||
const laden = async () => {
|
||||
const [caps, devs, info] = await Promise.allSettled([
|
||||
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()
|
||||
@@ -94,7 +110,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
}, [])
|
||||
|
||||
const schwach = schwacheEncoderCpu(workers)
|
||||
const presets = presetsFuer(schwach)
|
||||
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
|
||||
|
||||
@@ -108,6 +124,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
...bestehende,
|
||||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||||
mediaServer,
|
||||
transcodeEnabled,
|
||||
...presets,
|
||||
@@ -182,7 +199,11 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
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"
|
||||
/* Auf einem Windows-PC gibt es keinen "Encoding-Worker" und
|
||||
keinen Worker-Container -- Rippy komprimiert selbst. Der
|
||||
Commander am 28.08.2026: "Das gilt fuer die ganze
|
||||
standalone version fuer Windows." */
|
||||
titel={betrieb.kann.externe_worker ? 'Encoding-Worker' : 'Kompression'}
|
||||
geladen={geladen}
|
||||
gut={encoderWorker.length > 0}
|
||||
gutText={encoderWorker.map(w => {
|
||||
@@ -192,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
.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."
|
||||
schlechtText={betrieb.kann.externe_worker
|
||||
? '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.'
|
||||
: 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'}
|
||||
/>
|
||||
<Zeile
|
||||
titel="Freier Platz"
|
||||
@@ -232,6 +255,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
</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">
|
||||
@@ -301,11 +359,15 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
<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
|
||||
{betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein 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:
|
||||
@@ -316,11 +378,22 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
<strong>4K-UHD:</strong>{' '}
|
||||
{presets.transcodePresetUhd === PRESET_KEINE
|
||||
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
||||
: presets.transcodePresetUhd}
|
||||
: 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>
|
||||
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
||||
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { Card, CardHeader, CardTitle } from './ui/Card'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
|
||||
interface LiveLogEntry {
|
||||
id: string
|
||||
@@ -12,40 +11,14 @@ interface LiveLogEntry {
|
||||
}
|
||||
|
||||
export default function LiveLogSection() {
|
||||
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await api.get('/jobs')
|
||||
const jobs = response.data
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await api.get('/logs', { params: { limit: 50 } })
|
||||
setLogs(response.data)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Logs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchJobs()
|
||||
fetchLogs()
|
||||
const jobInterval = setInterval(fetchJobs, 5000)
|
||||
const logInterval = setInterval(fetchLogs, 5000)
|
||||
|
||||
return () => {
|
||||
clearInterval(jobInterval)
|
||||
clearInterval(logInterval)
|
||||
}
|
||||
}, [])
|
||||
// Vorher: zwei eigene Taktgeber (alle 5 s /jobs und /logs) = 24 Anfragen pro
|
||||
// Minute allein aus diesem Kasten. Jetzt kommt beides aus der einen offenen
|
||||
// Verbindung, die der Provider haelt.
|
||||
const { jobs, logs } = useStrom()
|
||||
const currentJob =
|
||||
jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') ||
|
||||
jobs.find((j: any) => j.status === 'pending') ||
|
||||
null
|
||||
|
||||
const getStatusColor = (level: string) => {
|
||||
switch (level) {
|
||||
@@ -72,7 +45,10 @@ export default function LiveLogSection() {
|
||||
<CardTitle>Live-Log</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{currentJob
|
||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
||||
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
|
||||
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
|
||||
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
|
||||
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
|
||||
: 'Kein aktiver Job'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Ein Pfadfeld mit „Durchsuchen …" — wie im Installer.
|
||||
*
|
||||
* ## Der Wunsch des Commanders (29.08.2026)
|
||||
*
|
||||
* > „Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das
|
||||
* > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt.
|
||||
* > Wie es der Installer auch macht"
|
||||
*
|
||||
* Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt
|
||||
* man den Ordner, danach musste man ihn abtippen. Einen Pfad wie
|
||||
* `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig.
|
||||
*
|
||||
* ## Warum eine eigene Datei
|
||||
*
|
||||
* Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre:
|
||||
* `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in
|
||||
* ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen.
|
||||
*
|
||||
* Der Browser selbst ist die API — `/browse` weiß seit dem 28.08.2026, in
|
||||
* welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste
|
||||
* der Laufwerke. Diese Komponente muss darüber nichts wissen.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowUp, Folder, FolderOpen } from 'lucide-react'
|
||||
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
|
||||
interface Eintrag {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (pfad: string) => void
|
||||
}
|
||||
|
||||
export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const [pfad, setPfad] = useState('')
|
||||
const [eltern, setEltern] = useState<string | null>(null)
|
||||
const [ordner, setOrdner] = useState<Eintrag[]>([])
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
|
||||
const laden = async (ziel: string) => {
|
||||
setLaedt(true)
|
||||
setFehler('')
|
||||
try {
|
||||
const r = await api.get('/browse', { params: { path: ziel } })
|
||||
setPfad(r.data.path || '')
|
||||
setEltern(r.data.parent)
|
||||
setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : [])
|
||||
} catch (e: any) {
|
||||
// Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen —
|
||||
// man kommt mit „nach oben" wieder heraus.
|
||||
setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar')
|
||||
setOrdner([])
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!offen) return
|
||||
// Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt
|
||||
// für /browse „oberste Ebene" — nativ also die Laufwerksliste.
|
||||
laden(value || '')
|
||||
}, [offen])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setOffen(true)}>
|
||||
Durchsuchen …
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={label}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||
<button
|
||||
onClick={() => eltern !== null && laden(eltern)}
|
||||
disabled={eltern === null}
|
||||
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="Eine Ebene höher"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
|
||||
{pfad || 'Laufwerke'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
|
||||
{laedt && (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen …</p>
|
||||
)}
|
||||
{!laedt && fehler && (
|
||||
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
|
||||
)}
|
||||
{!laedt && !fehler && ordner.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
|
||||
)}
|
||||
{ordner.map((o) => (
|
||||
<button
|
||||
key={o.path}
|
||||
onClick={() => laden(o.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" />
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<FolderOpen size={13} />
|
||||
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="amber"
|
||||
disabled={!pfad}
|
||||
onClick={() => {
|
||||
onChange(pfad)
|
||||
setOffen(false)
|
||||
}}
|
||||
>
|
||||
Diesen Ordner nutzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-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'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -20,6 +22,17 @@ export interface RipOptionen {
|
||||
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[]
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -35,6 +48,10 @@ interface WorkerWahl {
|
||||
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> = {
|
||||
@@ -80,6 +97,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
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
|
||||
@@ -102,6 +138,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
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')
|
||||
@@ -112,22 +158,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
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')
|
||||
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
|
||||
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||
const betrieb = useBetrieb()
|
||||
|
||||
/*
|
||||
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
|
||||
*
|
||||
* Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
|
||||
* Windows-PC gibt es die nicht — und fuer den Augenblick zwischen Oeffnen
|
||||
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
|
||||
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
|
||||
*/
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([])
|
||||
const [browsePath, setBrowsePath] = useState<string>('')
|
||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||
const [customPath, setCustomPath] = useState('')
|
||||
// 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('')
|
||||
// Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in
|
||||
// der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt
|
||||
// — und stünde leer da, obwohl etwas gewählt ist.
|
||||
const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -141,6 +207,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setGewaehlt(new Set())
|
||||
setEncoderNode('')
|
||||
setArbeitsDir('')
|
||||
setEigenesArbeitsZiel('')
|
||||
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([]))
|
||||
@@ -152,14 +223,19 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
setStandardArbeitsDir((s.workDir || '').trim())
|
||||
const basis = s.outputDir || '/app/media'
|
||||
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 || betrieb.ablage_vorgabe
|
||||
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(() => {})
|
||||
laden('/app/media')
|
||||
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
|
||||
// Normalfall gar nicht.
|
||||
}, [isOpen])
|
||||
|
||||
const laden = async (pfad: string) => {
|
||||
@@ -168,13 +244,53 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setBrowsePath(r.data.path)
|
||||
setBrowseParent(r.data.parent)
|
||||
setBrowseDirs(r.data.dirs)
|
||||
setBrowseFiles(r.data.files || [])
|
||||
} catch {
|
||||
setBrowseDirs([])
|
||||
setBrowseFiles([])
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
/*
|
||||
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
|
||||
*
|
||||
* Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er
|
||||
* holt sich das Arbeitsverzeichnis ja von der Installation."
|
||||
*
|
||||
* Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)",
|
||||
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
|
||||
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
|
||||
*/
|
||||
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
|
||||
// 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()) {
|
||||
@@ -186,6 +302,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
}
|
||||
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 (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
@@ -285,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
onChange={e => setArbeitsDir(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Standard aus den Einstellungen
|
||||
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
||||
Standard{arbeitsVorgabe ? ` — ${arbeitsVorgabe}` : ''}
|
||||
</option>
|
||||
{arbeitsZiele.map(z => (
|
||||
<option key={z.path} value={z.path}>
|
||||
@@ -294,10 +411,13 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||
</option>
|
||||
))}
|
||||
{eigenesArbeitsZiel && (
|
||||
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</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.
|
||||
Bei 4K-UHD bis zu 100 GB — nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben.
|
||||
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
||||
</p>
|
||||
</div>
|
||||
@@ -325,6 +445,31 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
</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">
|
||||
@@ -390,45 +535,162 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordner-Browser */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b 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(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>
|
||||
))}
|
||||
{/* 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('')
|
||||
}}
|
||||
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} />}
|
||||
{selectedType === 'music'
|
||||
? 'Anderen Zielordner wählen (nur für diesen Rip)'
|
||||
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (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 || 'Laufwerke'}
|
||||
</span>
|
||||
<Button
|
||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={!browsePath}
|
||||
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||
>
|
||||
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
|
||||
</Button>
|
||||
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
|
||||
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
|
||||
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
|
||||
beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar
|
||||
nicht („momentan geht das nicht", 28.08.2026). */}
|
||||
{selectedType !== 'music' && (
|
||||
<Button
|
||||
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={!browsePath}
|
||||
onClick={() => {
|
||||
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
|
||||
setEigenesArbeitsZiel(browsePath)
|
||||
setArbeitsDir(browsePath)
|
||||
}}
|
||||
>
|
||||
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
|
||||
</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">
|
||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
||||
{zielPfad}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
|
||||
interface WorkerInfo {
|
||||
name: string
|
||||
@@ -21,6 +22,7 @@ interface WorkerInfo {
|
||||
cpu_kerne?: string
|
||||
cpu_simd?: string
|
||||
handbrake_encoder?: string
|
||||
rippy_version?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ function relativeZeit(iso?: string): string {
|
||||
|
||||
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
|
||||
@@ -52,6 +55,7 @@ export default function WorkerVerwaltung() {
|
||||
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||
const [rippyHost, setRippyHost] = useState('')
|
||||
const { toast } = useToast()
|
||||
const strom = useStrom()
|
||||
|
||||
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||
@@ -68,16 +72,22 @@ export default function WorkerVerwaltung() {
|
||||
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') })
|
||||
}
|
||||
|
||||
// Die Worker-Liste kommt aus dem Live-Strom. /capabilities wird nur noch
|
||||
// EINMAL geholt — dort steht ausser der Liste die Server-Version, und die
|
||||
// aendert sich nur beim Deploy. Vorher lief der Abruf alle 15 s.
|
||||
useEffect(() => { laden() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
laden()
|
||||
const interval = setInterval(laden, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
if (strom.workers.length || strom.verbunden) {
|
||||
setWorkers(strom.workers as WorkerInfo[])
|
||||
}
|
||||
}, [strom.workers, strom.verbunden])
|
||||
|
||||
const installBefehl = variante === 'linux'
|
||||
? [
|
||||
@@ -146,6 +156,11 @@ export default function WorkerVerwaltung() {
|
||||
{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` : ''}
|
||||
|
||||
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
||||
}
|
||||
|
||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
|
||||
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
|
||||
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
|
||||
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(),
|
||||
label: type ? type.toUpperCase() : 'DISC',
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -33,6 +33,170 @@ interface WorkerFuerBewertung {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
||||
*
|
||||
* ## Der Befund des Commanders (28.08.2026)
|
||||
*
|
||||
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
||||
*
|
||||
* Worker erreichbar: 0 von 1
|
||||
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
||||
* Container-Platte: unbekannt
|
||||
* Freigaben: keine eingehängt
|
||||
*
|
||||
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
||||
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
||||
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
||||
* installation für Windows gebaut."
|
||||
*
|
||||
* ## Warum ein Provider und kein Abruf je Seite
|
||||
*
|
||||
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
||||
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
||||
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
||||
* Laufzeit nicht; er wird EINMAL geholt.
|
||||
*
|
||||
* ## Warum die Vorgabe „Docker" ist
|
||||
*
|
||||
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
||||
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
|
||||
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
|
||||
* einen Augenblick verschwindet und wieder auftaucht.
|
||||
*
|
||||
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
||||
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
||||
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
export interface BetriebsFaehigkeiten {
|
||||
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
||||
externe_worker: boolean
|
||||
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
||||
freigaben_einhaengen: boolean
|
||||
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
||||
container_pfade: boolean
|
||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||
werkzeuge_verwalten: boolean
|
||||
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||
frei_blaettern: boolean
|
||||
}
|
||||
|
||||
export interface Betrieb {
|
||||
modus: 'standalone' | 'verteilt'
|
||||
plattform: 'windows' | 'linux' | 'macos'
|
||||
im_container: boolean
|
||||
kann: BetriebsFaehigkeiten
|
||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||
ablage_vorgabe: string
|
||||
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
||||
arbeits_vorgabe: string
|
||||
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||
hilfe_befehl: string
|
||||
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
||||
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
||||
geladen: boolean
|
||||
}
|
||||
|
||||
const VORGABE: Betrieb = {
|
||||
modus: 'verteilt',
|
||||
plattform: 'linux',
|
||||
im_container: true,
|
||||
kann: {
|
||||
externe_worker: true,
|
||||
freigaben_einhaengen: true,
|
||||
container_pfade: true,
|
||||
werkzeuge_verwalten: false,
|
||||
frei_blaettern: false,
|
||||
},
|
||||
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
|
||||
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
|
||||
ablage_vorgabe: '',
|
||||
arbeits_vorgabe: '',
|
||||
hilfe_befehl: 'docker compose -p rippy ps',
|
||||
geladen: false,
|
||||
}
|
||||
|
||||
const BetriebContext = createContext<Betrieb>(VORGABE)
|
||||
|
||||
export function BetriebProvider({ children }: { children: ReactNode }) {
|
||||
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
||||
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
api.get('/betrieb')
|
||||
.then(antwort => {
|
||||
const d = antwort.data
|
||||
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
||||
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
||||
// verschwände ein Bereich stillschweigend.
|
||||
if (abgemeldet || !d || !d.kann) return
|
||||
setBetrieb({
|
||||
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
||||
plattform: d.plattform || 'linux',
|
||||
im_container: !!d.im_container,
|
||||
kann: {
|
||||
externe_worker: !!d.kann.externe_worker,
|
||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||
container_pfade: !!d.kann.container_pfade,
|
||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||
frei_blaettern: !!d.kann.frei_blaettern,
|
||||
},
|
||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||
hilfe_befehl: d.hilfe_befehl || '',
|
||||
geladen: true,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
||||
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
||||
})
|
||||
return () => { abgemeldet = true }
|
||||
}, [])
|
||||
|
||||
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
||||
}
|
||||
|
||||
export function useBetrieb(): Betrieb {
|
||||
return useContext(BetriebContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
|
||||
* (Windows-Client oder Docker-All-in-One)?
|
||||
*
|
||||
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
|
||||
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
|
||||
* die nächste falsche Annahme ein.
|
||||
*/
|
||||
export function useAlleinbetrieb(): boolean {
|
||||
return !useBetrieb().kann.externe_worker
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Der Live-Strom des UI — EINE Verbindung für die ganze Anwendung.
|
||||
*
|
||||
* ## Was das ablöst
|
||||
*
|
||||
* Bis hierher hatte jede Komponente ihren eigenen Taktgeber. Nachgerechnet an
|
||||
* den `setInterval`-Aufrufen verursachte EIN offener Tab plus ein Worker rund
|
||||
* 133 Anfragen pro Minute:
|
||||
*
|
||||
* Dashboard 5 Endpunkte alle 4 s -> 75/min
|
||||
* Log-Kasten 2 Endpunkte alle 5 s -> 24/min
|
||||
* Laufwerke 1 Endpunkt alle 5 s -> 12/min
|
||||
* Log-Seite 1 Endpunkt alle 10 s -> 6/min
|
||||
* Worker-Liste 1 Endpunkt alle 15 s -> 4/min
|
||||
* Windows-Tray /jobs alle 5 s -> 12/min
|
||||
*
|
||||
* Das Rate-Limit stand einmal UNTER dieser Zahl (100/min). Etwa jede vierte
|
||||
* Anfrage bekam 429 — und weil das UI einen Fehlschlag als „es gibt nichts"
|
||||
* verbuchte, leerte sich die Job-Liste im Sekundentakt. Der Commander meldete
|
||||
* das als „wird oft neu geladen".
|
||||
*
|
||||
* ## DIE Regel dieser Datei
|
||||
*
|
||||
* Ein Verbindungsabriss ist KEINE Aussage über die Welt.
|
||||
*
|
||||
* Bei einem Fehler wird der letzte bekannte Stand BEHALTEN und `verbunden`
|
||||
* auf false gesetzt — das UI zeigt dann ein Banner. Es wird niemals eine
|
||||
* Liste auf leer gesetzt. Genau das war der alte Fehler: fünfmal stand
|
||||
* `catch(() => [])` im Code, und jeder fehlgeschlagene Abruf hieß damit
|
||||
* „keine Jobs, keine Laufwerke, keine Ablagen".
|
||||
*
|
||||
* ## Warum ein Context und nicht ein Hook je Komponente
|
||||
*
|
||||
* `EventSource` öffnet je Aufruf eine eigene HTTP-Verbindung. Ein Hook, den
|
||||
* fünf Komponenten benutzen, wären fünf Verbindungen und fünf Snapshots —
|
||||
* also genau das Problem in neuer Form. Der Provider hält eine.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
export type Job = {
|
||||
id: string
|
||||
status: string
|
||||
progress: number
|
||||
title?: string | null
|
||||
type?: string
|
||||
device?: string
|
||||
error?: string | null
|
||||
meta?: Record<string, any> | null
|
||||
[k: string]: any
|
||||
}
|
||||
|
||||
export type Device = { id: string; name: string; type: string; path: string; status: string; [k: string]: any }
|
||||
export type Worker = { name: string; encoders?: string[]; info?: Record<string, any>; last_seen?: string | null }
|
||||
/**
|
||||
* Dieselbe Form, die auch `GET /logs` liefert (`_log_zeile` in main.py).
|
||||
* `timestamp`, nicht `ts` — sonst zeigt das Log-Fenster „Invalid Date", und
|
||||
* zwar NUR im Live-Betrieb.
|
||||
*/
|
||||
export type LogZeile = { id: string; timestamp?: string; level?: string; source?: string; message?: string }
|
||||
|
||||
export type StromZustand = {
|
||||
jobs: Job[]
|
||||
/** Server-Zustand aus dem langsamen Takt des Waechters. null = noch nichts gehoert. */
|
||||
systemInfo: any | null
|
||||
/** Ablageziele mit freiem Platz. null = noch nichts gehoert. */
|
||||
ablagen: any[] | null
|
||||
/** null heißt ausdrücklich „konnte nicht nachsehen" — NICHT „es gibt keine". */
|
||||
devices: Device[] | null
|
||||
workers: Worker[]
|
||||
logs: LogZeile[]
|
||||
/** false = Verbindung weg. Die Daten oben sind dann der letzte bekannte Stand. */
|
||||
verbunden: boolean
|
||||
/** Wie oft die Verbindung schon neu aufgebaut wurde — fürs Log, nicht fürs UI. */
|
||||
neuverbindungen: number
|
||||
/** Zeitpunkt des letzten empfangenen Ereignisses. */
|
||||
zuletzt: Date | null
|
||||
}
|
||||
|
||||
const LEER: StromZustand = {
|
||||
jobs: [],
|
||||
systemInfo: null,
|
||||
ablagen: null,
|
||||
devices: null,
|
||||
workers: [],
|
||||
logs: [],
|
||||
verbunden: false,
|
||||
neuverbindungen: 0,
|
||||
zuletzt: null,
|
||||
}
|
||||
|
||||
const StromContext = createContext<StromZustand>(LEER)
|
||||
|
||||
/** Wie viele Log-Zeilen im Speicher gehalten werden. */
|
||||
const LOG_GRENZE = 300
|
||||
|
||||
export function EventStreamProvider({ children }: { children: ReactNode }) {
|
||||
const [zustand, setZustand] = useState<StromZustand>(LEER)
|
||||
// In einem Ref, damit die Handler nicht bei jedem Zustandswechsel neu
|
||||
// gebunden werden müssen — sonst risse die Verbindung ständig ab.
|
||||
const neuverbindungen = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const quelle = new EventSource('/api/events')
|
||||
|
||||
const merken = (patch: Partial<StromZustand>) =>
|
||||
setZustand((alt) => ({ ...alt, ...patch, zuletzt: new Date() }))
|
||||
|
||||
// ── Snapshot: das ganze Bild ──────────────────────────────────────
|
||||
quelle.addEventListener('snapshot', (e) => {
|
||||
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
|
||||
merken({
|
||||
jobs: d.jobs ?? [],
|
||||
// devices === null kommt vom Server, wenn er die Laufwerke nicht
|
||||
// lesen konnte. Das wird DURCHGEREICHT, nicht zu [] geglättet.
|
||||
devices: d.devices === undefined ? null : d.devices,
|
||||
workers: d.workers ?? [],
|
||||
logs: d.logs ?? [],
|
||||
// ⚠️ Server-Zustand MIT übernehmen (Befund 28.08.2026).
|
||||
//
|
||||
// Er stand hier nicht, obwohl der Schnappschuss ihn liefert — und
|
||||
// `system.status` kommt erst nach bis zu 15 Sekunden. Nach jedem
|
||||
// Neuladen stand deshalb „Platz für Rippy: unbekannt" auf dem
|
||||
// Bildschirm. Das sieht aus wie eine Auskunft und ist keine.
|
||||
//
|
||||
// `undefined` heißt weiterhin „nicht mitgeschickt": dann bleibt der
|
||||
// alte Stand, statt auf leer zu fallen.
|
||||
systemInfo: d.info !== undefined ? d.info : null,
|
||||
ablagen: d.ablagen !== undefined ? d.ablagen : null,
|
||||
verbunden: true,
|
||||
})
|
||||
})
|
||||
|
||||
// ── Job-Deltas ────────────────────────────────────────────────────
|
||||
const jobPatch = (e: Event) => {
|
||||
const ereignis = JSON.parse((e as MessageEvent).data)
|
||||
const id = ereignis.entitaet_id
|
||||
const daten = ereignis.daten ?? {}
|
||||
setZustand((alt) => {
|
||||
if (daten.status === 'geloescht') {
|
||||
return { ...alt, jobs: alt.jobs.filter((j) => j.id !== id), verbunden: true, zuletzt: new Date() }
|
||||
}
|
||||
const bekannt = alt.jobs.some((j) => j.id === id)
|
||||
const jobs = bekannt
|
||||
? alt.jobs.map((j) => (j.id === id ? { ...j, ...daten } : j))
|
||||
: [{ id, ...daten } as Job, ...alt.jobs]
|
||||
return { ...alt, jobs, verbunden: true, zuletzt: new Date() }
|
||||
})
|
||||
}
|
||||
for (const typ of ['job.created', 'job.progress', 'job.phase', 'job.finished']) {
|
||||
quelle.addEventListener(typ, jobPatch)
|
||||
}
|
||||
|
||||
// ── Laufwerke ─────────────────────────────────────────────────────
|
||||
quelle.addEventListener('drive.changed', (e) => {
|
||||
const ereignis = JSON.parse((e as MessageEvent).data)
|
||||
setZustand((alt) => {
|
||||
if (alt.devices === null) return alt // noch kein Stand — auf Snapshot warten
|
||||
const id = ereignis.entitaet_id
|
||||
return {
|
||||
...alt,
|
||||
devices: alt.devices.map((d) => (d.id === id ? { ...d, ...ereignis.daten } : d)),
|
||||
verbunden: true,
|
||||
zuletzt: new Date(),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Log-Zeilen ────────────────────────────────────────────────────
|
||||
quelle.addEventListener('log.line', (e) => {
|
||||
const ereignis = JSON.parse((e as MessageEvent).data)
|
||||
const d = ereignis.daten ?? {}
|
||||
const zeile: LogZeile = {
|
||||
id: String(ereignis.entitaet_id),
|
||||
timestamp: ereignis.ts,
|
||||
level: d.level,
|
||||
source: d.source,
|
||||
message: d.message,
|
||||
}
|
||||
setZustand((alt) => ({
|
||||
...alt,
|
||||
logs: [zeile, ...alt.logs].slice(0, LOG_GRENZE),
|
||||
verbunden: true,
|
||||
zuletzt: new Date(),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Server-Zustand (langsamer Takt) ───────────────────────────────
|
||||
quelle.addEventListener('system.status', (e) => {
|
||||
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
|
||||
// Nur uebernehmen, was WIRKLICH mitgeschickt wurde. Ein fehlender
|
||||
// Schluessel heisst „konnte ich diesmal nicht lesen" — dann bleibt der
|
||||
// alte Stand stehen, statt auf leer zu fallen.
|
||||
setZustand((alt) => ({
|
||||
...alt,
|
||||
systemInfo: d.info !== undefined ? d.info : alt.systemInfo,
|
||||
workers: d.workers !== undefined ? d.workers : alt.workers,
|
||||
ablagen: d.ablagen !== undefined ? d.ablagen : alt.ablagen,
|
||||
verbunden: true,
|
||||
zuletzt: new Date(),
|
||||
}))
|
||||
})
|
||||
|
||||
quelle.onopen = () => merken({ verbunden: true })
|
||||
|
||||
// ── Abriss: Stand BEHALTEN, nur die Verbindung melden ─────────────
|
||||
quelle.onerror = () => {
|
||||
// Kein Leeren, kein Zurücksetzen. Der Browser verbindet von selbst neu
|
||||
// und schickt dabei Last-Event-ID mit; der Server liefert die Lücke nach
|
||||
// oder schickt einen frischen Snapshot.
|
||||
neuverbindungen.current += 1
|
||||
setZustand((alt) => ({
|
||||
...alt,
|
||||
verbunden: false,
|
||||
neuverbindungen: neuverbindungen.current,
|
||||
}))
|
||||
}
|
||||
|
||||
return () => quelle.close()
|
||||
}, [])
|
||||
|
||||
const wert = useMemo(() => zustand, [zustand])
|
||||
return <StromContext.Provider value={wert}>{children}</StromContext.Provider>
|
||||
}
|
||||
|
||||
/** Der gemeinsame Live-Zustand. */
|
||||
export function useStrom(): StromZustand {
|
||||
return useContext(StromContext)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||
return (
|
||||
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
|
||||
|
||||
export default function AnleitungPage() {
|
||||
const fett = "font-semibold text-slate-100"
|
||||
/*
|
||||
* Auch die Anleitung haengt am Betrieb.
|
||||
*
|
||||
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||||
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||||
*
|
||||
* Eine Anleitung, die einem Windows-Nutzer erklaert, wie er Freigaben
|
||||
* einhaengt und weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
|
||||
* das er nicht hat. Das ist schlimmer als gar keine Anleitung: Sie laesst
|
||||
* ihn glauben, er habe etwas falsch gemacht.
|
||||
*/
|
||||
const betrieb = useBetrieb()
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HardDrive} titel="NAS & Speicherziele">
|
||||
<Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
|
||||
{betrieb.kann.freigaben_einhaengen ? (
|
||||
<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>
|
||||
Einstellungen → Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
|
||||
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
|
||||
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
|
||||
ein Netzwerkpfad wie <code>\NAS\Filme</code> — Rippy schreibt einfach dorthin.
|
||||
Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig.
|
||||
</p>
|
||||
)}
|
||||
<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.
|
||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
|
||||
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen →
|
||||
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
|
||||
Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
{/* Nur im verteilten Betrieb. Einem Windows-Nutzer zu erklaeren,
|
||||
wie er weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
|
||||
das er nicht hat — und laesst ihn glauben, er habe etwas falsch
|
||||
gemacht (Commander-Befund 28.08.2026). */}
|
||||
{betrieb.kann.externe_worker && (
|
||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||
<p>
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||
@@ -126,6 +154,7 @@ export default function AnleitungPage() {
|
||||
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
|
||||
</p>
|
||||
</Abschnitt>
|
||||
)}
|
||||
|
||||
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||
<p>
|
||||
|
||||
@@ -9,6 +9,9 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
import JobDetailModal from '../components/JobDetailModal'
|
||||
import LiveLogSection from '../components/LiveLogSection'
|
||||
import RetryDialog from '../components/RetryDialog'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
interface JobMeta {
|
||||
year?: number
|
||||
@@ -30,27 +33,85 @@ interface Job {
|
||||
progress: number
|
||||
title?: string
|
||||
can_retry?: boolean
|
||||
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
|
||||
retry_art?: string
|
||||
meta?: JobMeta | null
|
||||
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||
eta_sekunden?: number
|
||||
eta_text?: string
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
api_version: string
|
||||
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
||||
plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean;
|
||||
frei_gb: number; gesamt_gb: number }[]
|
||||
workers: { name: string; encoders: string[] }[]
|
||||
}
|
||||
|
||||
// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit.
|
||||
// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker
|
||||
// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026).
|
||||
interface WorkerLive {
|
||||
name: string
|
||||
encoders: string[]
|
||||
online?: boolean
|
||||
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
||||
}
|
||||
|
||||
// Ein Laufwerk aus GET /devices. Gebraucht, weil die Server-Status-Karte sonst
|
||||
// „keine Disc in Arbeit" behauptet, während oben auf DEMSELBEN Bildschirm
|
||||
// „Akira im Laufwerk erkannt" steht (Commander-Befund 26.07.2026).
|
||||
interface LaufwerkLive {
|
||||
id: string
|
||||
status: string
|
||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||
/** Die Erkennung laeuft noch — es gibt noch keinen Titel, aber auch kein
|
||||
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
|
||||
disc_wird_erkannt?: boolean
|
||||
}
|
||||
|
||||
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||
// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz.
|
||||
//
|
||||
// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media
|
||||
// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit
|
||||
// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem
|
||||
// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für
|
||||
// eine Disc ist, sah die falsche Zahl.
|
||||
interface AblageZiel {
|
||||
name: string
|
||||
path: string
|
||||
is_mount: boolean
|
||||
free_gb: number | 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}`
|
||||
}
|
||||
|
||||
async function fetchJobs(): Promise<Job[]> {
|
||||
/*
|
||||
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
|
||||
*
|
||||
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
|
||||
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
|
||||
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
|
||||
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
|
||||
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
|
||||
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
|
||||
* sprangen auf (0), vier Sekunden später war alles wieder da.
|
||||
*
|
||||
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
|
||||
* weiß, behält, was er wusste.
|
||||
*/
|
||||
async function fetchJobs(): Promise<Job[] | null> {
|
||||
try {
|
||||
const response = await api.get('/jobs')
|
||||
return response.data
|
||||
return Array.isArray(response.data) ? response.data : null
|
||||
} catch {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +121,109 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
|
||||
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
|
||||
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
|
||||
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
||||
const strom = useStrom()
|
||||
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
|
||||
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
|
||||
const betrieb = useBetrieb()
|
||||
const { toast } = useToast()
|
||||
|
||||
const jobLoeschen = async (job: Job) => {
|
||||
/*
|
||||
* Entfernen fragt jetzt ZUERST, was daneben liegen bleibt.
|
||||
*
|
||||
* Grund (Vorfall 25.07.2026, v3.14): „Job aus der Liste entfernen" löscht
|
||||
* bewusst keine Dateien. Das ist richtig — aber Job und Rohdaten sind nur
|
||||
* über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR:
|
||||
* nichts zeigt mehr darauf, „Neu komprimieren" ist unmöglich, und im UI ist
|
||||
* nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der
|
||||
* Platte, gefunden erst per SSH.
|
||||
*/
|
||||
const loeschenVorbereiten = async (job: Job) => {
|
||||
setLoeschKandidat(job)
|
||||
setRohdaten(null)
|
||||
setRohdatenMitloeschen(false)
|
||||
try {
|
||||
await api.delete(`/jobs/${job.id}`)
|
||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
||||
setJobs(await fetchJobs())
|
||||
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||
setRohdaten(r.data)
|
||||
} catch {
|
||||
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
|
||||
*
|
||||
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird
|
||||
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
|
||||
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
|
||||
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
|
||||
*
|
||||
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
|
||||
* 'transcode' → der Rip war fertig → „Neu komprimieren"
|
||||
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
|
||||
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
|
||||
* Entscheidungshilfe
|
||||
*
|
||||
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
|
||||
* false) — dann hilft auch hier nur ein neuer Rip.
|
||||
*/
|
||||
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
|
||||
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
|
||||
if (job.retry_art === 'rip') return 'rip'
|
||||
return 'unklar'
|
||||
}
|
||||
|
||||
const wiederholen = async (job: Job) => {
|
||||
setNeuKandidat(job)
|
||||
setNeuRohdaten(null)
|
||||
try {
|
||||
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||
setNeuRohdaten(r.data)
|
||||
} catch {
|
||||
setNeuRohdaten(null) // dann eben ohne Zahl fragen
|
||||
}
|
||||
}
|
||||
|
||||
const neuStarten = async (art: 'transcode' | 'rip') => {
|
||||
const job = neuKandidat
|
||||
if (!job) return
|
||||
setNeuKandidat(null)
|
||||
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
|
||||
try {
|
||||
await api.post(`/jobs/${job.id}/${weg}`)
|
||||
toast('success', art === 'rip'
|
||||
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
|
||||
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const jobLoeschen = async () => {
|
||||
const job = loeschKandidat
|
||||
if (!job) return
|
||||
setLoeschKandidat(null)
|
||||
try {
|
||||
const r = await api.delete(`/jobs/${job.id}`, {
|
||||
params: rohdatenMitloeschen ? { rohdaten: true } : {},
|
||||
})
|
||||
const befreit = r.data?.rohdaten_geloescht_gb
|
||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
||||
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||
}
|
||||
@@ -79,52 +234,111 @@ export default function Dashboard() {
|
||||
try {
|
||||
const r = await api.delete('/jobs')
|
||||
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||
setJobs(await fetchJobs())
|
||||
const frisch = await fetchJobs()
|
||||
if (frisch) setJobs(frisch)
|
||||
} catch {
|
||||
toast('error', 'Aufräumen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* KEIN TAKTGEBER MEHR (Etappe V2-3).
|
||||
*
|
||||
* Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
|
||||
* Worker und Ablagen — zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
|
||||
* der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
|
||||
* (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
|
||||
* „es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
|
||||
* Genau das hat der Commander als „wird oft neu geladen" gemeldet.
|
||||
*
|
||||
* Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
|
||||
*
|
||||
* ⚠️ DIE REGEL BLEIBT — sie ist nur umgezogen: Ein fehlender Wert im Strom
|
||||
* heisst „nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
|
||||
* jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
|
||||
* letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
|
||||
* „VERBINDUNG WEG" statt leerer Listen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [jobsData, sysData] = await Promise.all([
|
||||
fetchJobs(),
|
||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||
])
|
||||
setJobs(jobsData)
|
||||
if (sysData) setSystemInfo(sysData)
|
||||
setJobs(strom.jobs as Job[])
|
||||
setLoading(false)
|
||||
}, [strom.jobs])
|
||||
|
||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
|
||||
}, [strom.devices])
|
||||
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 4000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
|
||||
}, [strom.workers, strom.verbunden])
|
||||
|
||||
useEffect(() => {
|
||||
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
|
||||
}, [strom.systemInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
|
||||
}, [strom.ablagen])
|
||||
|
||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||
|
||||
/*
|
||||
* Was diese Karte anzeigt — und was sie NICHT mehr anzeigt.
|
||||
*
|
||||
* Commander 26.07.2026: „Der Server Status muss dringend überarbeitet werden,
|
||||
* das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die
|
||||
* Karte hieß „Echte Live-Daten" und enthielt drei Angaben, von denen zwei
|
||||
* erfunden waren.
|
||||
*
|
||||
* 1. „Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der
|
||||
* Fortschritt des Jobs — bzw. eine feste 15/5, wenn keiner lief. Rippy
|
||||
* misst nirgends CPU-Last, also wird sie auch nicht behauptet.
|
||||
* 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die
|
||||
* wie eine Messung aussah.
|
||||
* 3. „N Worker Online" zählte die registrierten Einträge aus
|
||||
* /system/info — auch Leichen alter Container-Rebuilds. Die echte
|
||||
* Erreichbarkeit steht in /capabilities (Celery-Ping).
|
||||
*
|
||||
* Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit
|
||||
* Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz.
|
||||
*/
|
||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||
const workerCount = systemInfo?.workers?.length || 1
|
||||
|
||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||
const y = 50 - (val / 100) * 40
|
||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
}).join(' ')
|
||||
const workerOnline = workersLive.filter(w => w.online)
|
||||
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||
/*
|
||||
* Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||
*
|
||||
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
|
||||
* stand hier stur „Bereit — keine Disc in Arbeit / Disc einlegen, Rippy erkennt
|
||||
* sie selbst", während oben auf DEMSELBEN Bildschirm „Akira im Laufwerk
|
||||
* erkannt" prangte. Zwei Aussagen, ein Blick, Widerspruch — und der Nutzer
|
||||
* weiß nicht, welcher er glauben soll.
|
||||
*/
|
||||
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
|
||||
// Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von
|
||||
// /app/media (movies/series/music liegen auf der Container-Platte und sind
|
||||
// dort schon mitgezählt).
|
||||
const freigaben = ablagen.filter(a => a.is_mount)
|
||||
const phaseText = aktiverJob
|
||||
? (aktiverJob.status === 'transcoding'
|
||||
? 'Kompression läuft (HandBrake)'
|
||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||
: discImLaufwerk
|
||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
|
||||
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
|
||||
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
|
||||
// Fehler (Commander 29.08.2026).
|
||||
: laufwerke.some(l => l.disc_wird_erkannt)
|
||||
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
|
||||
: 'Bereit — kein Datenträger im Laufwerk'
|
||||
|
||||
const filteredJobs = jobs.filter(j => {
|
||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||
@@ -271,11 +485,17 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||
<div className="w-36 space-y-1 text-right">
|
||||
<div className="w-40 space-y-1 text-right">
|
||||
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||
</div>
|
||||
{/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026
|
||||
unsichtbar machte. Ohne belastbare Messreihe steht hier
|
||||
ehrlich, dass noch gemessen wird (siehe api/eta.py). */}
|
||||
<div className="text-[11px] font-mono text-slate-300">
|
||||
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||
Details
|
||||
@@ -360,6 +580,9 @@ export default function Dashboard() {
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{job.eta_text && (
|
||||
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||
@@ -371,31 +594,63 @@ export default function Dashboard() {
|
||||
Details
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
Abbrechen DIREKT in der Zeile (Commander
|
||||
29.08.2026: „man kann einen rip garnicht
|
||||
abbrechen").
|
||||
|
||||
Es gab genau einen Abbrechen-Knopf, und der
|
||||
steckte in der Kachel „laufender Job". Die
|
||||
erschien nur bei Status `processing` — der
|
||||
Ereignisstrom lieferte aber den rohen
|
||||
DB-Status `running`. Also keine Kachel, also
|
||||
kein Knopf, also kein Weg zurueck.
|
||||
Die Ursache ist behoben; ein zweiter Weg zum
|
||||
Abbrechen ist trotzdem richtig, denn genau
|
||||
hier sucht man ihn.
|
||||
*/}
|
||||
{['processing', 'transcoding', 'pending'].includes(job.status) && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => api.post(`/jobs/${job.id}/cancel`)
|
||||
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
|
||||
.catch((e: any) => toast('error',
|
||||
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{job.status === 'canceling' && (
|
||||
<span className="text-xs font-mono text-slate-400">bricht ab …</span>
|
||||
)}
|
||||
|
||||
{job.status === 'completed' && (
|
||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||
<Download size={13} /> Download
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{job.status === 'failed' && job.can_retry && (
|
||||
{job.status === 'failed' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
||||
}}
|
||||
onClick={() => wiederholen(job)}
|
||||
title="Fragt vorher, was genau wiederholt wird"
|
||||
>
|
||||
<RotateCcw size={13} /> Neu
|
||||
<RotateCcw size={13} />
|
||||
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
|
||||
: retryPlan(job) === 'rip' ? 'Neu rippen'
|
||||
: 'Neu …'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(job.status === 'completed' || job.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => jobLoeschen(job)}
|
||||
onClick={() => loeschenVorbereiten(job)}
|
||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
title="Löschen"
|
||||
title="Aus der Liste entfernen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -421,58 +676,226 @@ export default function Dashboard() {
|
||||
<CardHeader className="border-b border-slate-800">
|
||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||
<Server size={18} className="text-amber-400" />
|
||||
Server Status (Echte Live-Daten)
|
||||
Server-Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 flex-1 flex items-center">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
||||
<div className="space-y-4 flex-1 w-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
||||
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-5 flex-1 flex flex-col justify-center">
|
||||
<div className="space-y-4 w-full">
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
||||
<span className="text-slate-200">{workerCount} Worker Online</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
||||
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
|
||||
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
|
||||
<Activity size={14} className="text-amber-400" /> Gerade:
|
||||
</span>
|
||||
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
|
||||
{phaseText}
|
||||
</span>
|
||||
</div>
|
||||
{aktiverJob ? (
|
||||
<>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||
<span className="text-slate-300 truncate pr-2">
|
||||
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
|
||||
</span>
|
||||
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
|
||||
das auch so gesagt — nicht geraten (siehe eta.py). */}
|
||||
<span className="text-amber-400 flex-shrink-0">
|
||||
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
{queueJobs.length > 0
|
||||
? `${queueJobs.length} Job(s) warten in der Schlange`
|
||||
: discImLaufwerk
|
||||
? `${discImLaufwerk.disc!.title}`
|
||||
+ (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '')
|
||||
+ ' liegt bereit — oben auf „Rippen starten"'
|
||||
: 'Disc einlegen — Rippy erkennt sie selbst'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
||||
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
||||
<path
|
||||
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
{/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
|
||||
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
|
||||
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
|
||||
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
|
||||
keinen zweiten Worker, auf den man warten könnte, und
|
||||
kein docker compose, das man prüfen könnte. Die Meldung
|
||||
war nicht nur unpassend, sie war falsch. */}
|
||||
{!betrieb.kann.externe_worker ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
|
||||
</span>
|
||||
<span className="text-slate-200">
|
||||
auf diesem Rechner
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
{aktiverJob
|
||||
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
|
||||
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
|
||||
</span>
|
||||
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
|
||||
{workerOnline.length} von {workersLive.length}
|
||||
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{workerOnline.length === 0 ? (
|
||||
<p className="text-[11px] font-mono text-rose-400">
|
||||
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||
{betrieb.hilfe_befehl && (
|
||||
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{workerOnline.map(w => (
|
||||
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
|
||||
<span className="text-emerald-400">●</span> {w.name}
|
||||
{w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''}
|
||||
{w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''}
|
||||
{w.info?.extern === 'ja' ? ' · extern' : ''}
|
||||
{(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''}
|
||||
</p>
|
||||
))}
|
||||
{encoderWorker.length === 0 && (
|
||||
<p className="text-[11px] font-mono text-amber-400">
|
||||
Keiner dieser Worker hat HandBrake — es wird nur gerippt, nicht komprimiert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
||||
Vorher stand hier nur die Container-Platte; wer wissen
|
||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||
<div>
|
||||
{/*
|
||||
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
|
||||
‚Platz für rippy' sollte eher das arbeitsverzeichnis und
|
||||
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
|
||||
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
|
||||
*/}
|
||||
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
|
||||
<div key={ort.name + ort.pfad} className="mb-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="text-amber-400" />
|
||||
{ort.name}:
|
||||
</span>
|
||||
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
|
||||
? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
{ort.pfad && (
|
||||
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
|
||||
{ort.pfad}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!betrieb.kann.container_pfade
|
||||
&& (systemInfo?.plaetze || []).length > 1
|
||||
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
|
||||
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
|
||||
statt die zweite Zeile wegzulassen. */
|
||||
<p className="text-[11px] font-mono text-slate-500 mb-1">
|
||||
Beide auf demselben Laufwerk — der Platz wird geteilt.
|
||||
</p>
|
||||
)}
|
||||
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
|
||||
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="text-amber-400" />
|
||||
Container-Platte:
|
||||
</span>
|
||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
|
||||
}`}
|
||||
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (
|
||||
<p className="text-[11px] font-mono text-amber-400 mt-1">
|
||||
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB —
|
||||
das reicht nicht mehr für jede Disc.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
||||
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN.
|
||||
|
||||
⚠️ NUR im Container. Unter Windows hängt Rippy nichts ein:
|
||||
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
|
||||
Der Satz „Ohne Freigabe kann ein externer Encoder nichts
|
||||
tun — er sieht die Container-Platte nicht" stand am
|
||||
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
|
||||
Es gibt weder einen externen Encoder noch eine
|
||||
Container-Platte. */}
|
||||
{betrieb.kann.freigaben_einhaengen && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Server size={14} className="text-amber-400" /> Freigaben:
|
||||
</span>
|
||||
<span className={freigaben.length > 0 ? 'text-slate-200' : 'text-slate-400'}>
|
||||
{freigaben.length > 0
|
||||
? `${freigaben.length} eingehängt`
|
||||
: 'keine eingehängt'}
|
||||
</span>
|
||||
</div>
|
||||
{freigaben.length === 0 ? (
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
Ohne Freigabe kann ein externer Encoder nichts tun — er sieht die
|
||||
Container-Platte nicht. Einhängen: Einstellungen → Speicherziele.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{freigaben.map(f => (
|
||||
<p key={f.path} className="text-[11px] font-mono text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">
|
||||
<span className="text-emerald-400">●</span> {f.name}
|
||||
</span>
|
||||
<span className={f.free_gb != null && f.free_gb < 60
|
||||
? 'text-amber-400 flex-shrink-0'
|
||||
: 'text-slate-300 flex-shrink-0'}>
|
||||
{f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -548,6 +971,49 @@ export default function Dashboard() {
|
||||
confirmText="Aufräumen"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
|
||||
<RetryDialog
|
||||
offen={neuKandidat !== null}
|
||||
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
|
||||
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
|
||||
rohdaten={neuRohdaten}
|
||||
onClose={() => setNeuKandidat(null)}
|
||||
onStart={neuStarten}
|
||||
/>
|
||||
|
||||
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
||||
<ConfirmDialog
|
||||
isOpen={loeschKandidat !== null}
|
||||
onClose={() => setLoeschKandidat(null)}
|
||||
onConfirm={jobLoeschen}
|
||||
title={`„${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`}
|
||||
message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind."
|
||||
confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'}
|
||||
type="warning"
|
||||
extra={rohdaten && rohdaten.gb > 0 ? (
|
||||
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
|
||||
<p>
|
||||
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
|
||||
{rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}.
|
||||
Ohne den Eintrag zeigt nichts mehr darauf — sie sind dann über Rippy
|
||||
nicht mehr erreichbar und „Neu komprimieren" ist unmöglich.
|
||||
</p>
|
||||
{rohdaten.pfade.map(p => (
|
||||
<p key={p} className="font-mono break-all opacity-80">{p}</p>
|
||||
))}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rohdatenMitloeschen}
|
||||
onChange={e => setRohdatenMitloeschen(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
|
||||
/>
|
||||
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '../context/ToastContext'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
@@ -21,6 +22,7 @@ export default function LogsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const strom = useStrom()
|
||||
|
||||
const refreshLogs = async (manuell = false) => {
|
||||
if (manuell) setRefreshing(true)
|
||||
@@ -37,11 +39,20 @@ export default function LogsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Einmal die volle Historie holen (200 Zeilen — der Strom liefert im
|
||||
// Snapshot nur die letzten 50), danach kommt alles Neue von selbst.
|
||||
// Der Aktualisieren-Knopf bleibt: Er holt die Historie erneut.
|
||||
useEffect(() => { refreshLogs() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshLogs()
|
||||
const interval = setInterval(refreshLogs, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
if (!strom.logs.length) return
|
||||
setLogs((alt) => {
|
||||
const bekannt = new Set(alt.map((l) => String(l.id)))
|
||||
const neue = strom.logs.filter((l) => !bekannt.has(String(l.id)))
|
||||
return neue.length ? ([...neue, ...alt] as LogEntry[]) : alt
|
||||
})
|
||||
setLoading(false)
|
||||
}, [strom.logs])
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
@@ -70,10 +81,28 @@ export default function LogsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLogs = filter === 'all'
|
||||
? logs
|
||||
const filteredLogs = filter === 'all'
|
||||
? 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,
|
||||
@@ -162,6 +191,25 @@ export default function LogsPage() {
|
||||
{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
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{quelleLabel(q)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
|
||||
@@ -4,10 +4,12 @@ import { api } from '../lib/api'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||
@@ -38,13 +40,20 @@ interface SettingsState {
|
||||
jellyfinApiKey: string
|
||||
workDir: string
|
||||
makemkvAppKey: string
|
||||
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
|
||||
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
|
||||
audioSprachen: string
|
||||
untertitelSprachen: string
|
||||
}
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
tmdbApiKey: '',
|
||||
tvdbApiKey: '',
|
||||
omdbApiKey: '',
|
||||
outputDir: '/app/media',
|
||||
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
|
||||
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
|
||||
// beim Laden gesetzt.
|
||||
outputDir: '',
|
||||
movieDir: 'movies',
|
||||
seriesDir: 'series',
|
||||
musicDir: 'music',
|
||||
@@ -63,6 +72,11 @@ const defaultSettings: SettingsState = {
|
||||
jellyfinApiKey: '',
|
||||
workDir: '',
|
||||
makemkvAppKey: '',
|
||||
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
|
||||
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
|
||||
// ohne dass jemand danach gefragt hat.
|
||||
audioSprachen: '',
|
||||
untertitelSprachen: '',
|
||||
}
|
||||
|
||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||
@@ -127,6 +141,37 @@ interface SystemInfo {
|
||||
webhook_gesetzt: boolean
|
||||
}
|
||||
|
||||
// Antwort von GET /presets — die Preset-Namen kommen aus
|
||||
// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
|
||||
// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
|
||||
// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
|
||||
// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
|
||||
// lässt die Kompression scheitern.
|
||||
interface PresetEmpfehlung {
|
||||
preset: string
|
||||
grund: string
|
||||
gefunden: boolean
|
||||
}
|
||||
|
||||
interface PresetTyp {
|
||||
empfehlung: PresetEmpfehlung
|
||||
auswahl: { name: string, verkleinert: boolean }[]
|
||||
}
|
||||
|
||||
interface PresetUebersicht {
|
||||
quelle: 'worker' | 'rueckfall'
|
||||
hardware: string[]
|
||||
simd_schnell: boolean
|
||||
typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
|
||||
}
|
||||
|
||||
// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
|
||||
const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
|
||||
uhd: 'transcodePresetUhd',
|
||||
bluray: 'transcodePresetBluray',
|
||||
dvd: 'transcodePresetDvd',
|
||||
}
|
||||
|
||||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||||
function bytesLesbar(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
@@ -147,6 +192,7 @@ function zeitLesbar(iso: string): string {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||
const betrieb = useBetrieb()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
@@ -168,9 +214,14 @@ export default function SettingsPage() {
|
||||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||||
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
|
||||
const [keyHolt, setKeyHolt] = useState(false)
|
||||
const [keyMeldung, setKeyMeldung] = useState('')
|
||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
|
||||
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -206,6 +257,43 @@ export default function SettingsPage() {
|
||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||
}
|
||||
|
||||
/*
|
||||
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
|
||||
*
|
||||
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
|
||||
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
|
||||
* zwei Wege, ihn zu verlieren.
|
||||
*/
|
||||
const keyJetztHolen = async () => {
|
||||
setKeyHolt(true)
|
||||
setKeyMeldung('')
|
||||
try {
|
||||
const r = await api.post('/system/makemkv-key/holen')
|
||||
const antwort = await api.get('/settings')
|
||||
setSettings({ ...defaultSettings, ...antwort.data })
|
||||
if (r.data?.angenommen === false) {
|
||||
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
|
||||
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
|
||||
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
|
||||
// trotzdem.
|
||||
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
|
||||
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
|
||||
} else {
|
||||
setKeyMeldung(r.data?.geaendert
|
||||
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||||
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||||
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
const grund = e?.response?.data?.detail
|
||||
|| 'Das Forum ist nicht erreichbar.'
|
||||
setKeyMeldung('Fehlgeschlagen: ' + grund)
|
||||
toast('error', grund)
|
||||
} finally {
|
||||
setKeyHolt(false)
|
||||
}
|
||||
}
|
||||
|
||||
const keydbHochladen = async (datei: File) => {
|
||||
setKeydbBusy(true)
|
||||
try {
|
||||
@@ -291,8 +379,90 @@ export default function SettingsPage() {
|
||||
api.get('/system/aacs-dumps')
|
||||
.then(r => setDumps(r.data?.dumps || []))
|
||||
.catch(() => setDumps([]))
|
||||
api.get('/presets')
|
||||
.then(r => setPresetInfo(r.data))
|
||||
.catch(() => setPresetInfo(null))
|
||||
}, [])
|
||||
|
||||
// „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
|
||||
// drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
|
||||
// gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
|
||||
const bestePresetsUebernehmen = () => {
|
||||
if (!presetInfo) return
|
||||
const neu: Partial<SettingsState> = {}
|
||||
const uebernommen: string[] = []
|
||||
for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
|
||||
const e = presetInfo.typen[typ]?.empfehlung
|
||||
if (!e?.gefunden || !e.preset) continue
|
||||
neu[PRESET_FELD[typ]] = e.preset as any
|
||||
uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
|
||||
}
|
||||
if (uebernommen.length === 0) {
|
||||
toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
|
||||
return
|
||||
}
|
||||
setSettings(prev => ({ ...prev, ...neu }))
|
||||
setSaved(false)
|
||||
toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
|
||||
}
|
||||
|
||||
// Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
|
||||
// PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
|
||||
// steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
|
||||
// Seite still auf etwas anderes springen.
|
||||
const presetAuswahl = (
|
||||
typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
|
||||
) => {
|
||||
const feld = PRESET_FELD[typ]
|
||||
const wert = (settings[feld] as string) || ''
|
||||
const daten = presetInfo?.typen?.[typ]
|
||||
const empfehlung = daten?.empfehlung
|
||||
const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
|
||||
const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
|
||||
const bekannt = wert === PRESET_KEINE
|
||||
|| (daten?.auswahl || []).some(o => o.name === wert)
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
|
||||
<option value={PRESET_KEINE}>{keineText}</option>
|
||||
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
|
||||
BENANNT statt verschluckt — genau so fällt eine falsche
|
||||
Bestandseinstellung überhaupt auf. */}
|
||||
{!bekannt && wert && (
|
||||
<option value={wert}>{wert} — gespeichert, dieser Worker kennt es nicht</option>
|
||||
)}
|
||||
{passend.length > 0 && (
|
||||
<optgroup label="Passt zur Auflösung">
|
||||
{passend.map(o => (
|
||||
<option key={o.name} value={o.name}>
|
||||
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{kleiner.length > 0 && (
|
||||
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
|
||||
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</Select>
|
||||
{empfehlung && empfehlung.preset !== wert && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{empfehlung.gefunden ? (
|
||||
<>
|
||||
Empfohlen wäre{' '}
|
||||
<strong className="text-slate-700 dark:text-slate-300">
|
||||
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
|
||||
</strong>
|
||||
{' — '}{empfehlung.grund}
|
||||
</>
|
||||
) : empfehlung.grund}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const webhookTesten = async () => {
|
||||
if (!settings.notificationWebhook) return
|
||||
setTestBusy(true)
|
||||
@@ -361,7 +531,12 @@ export default function SettingsPage() {
|
||||
const istFreigabe = (pfad: string) =>
|
||||
ziele.some(z => z.path === pfad && z.is_mount)
|
||||
const ablageWarnung = (() => {
|
||||
const ablage = settings.outputDir || '/app/media'
|
||||
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
|
||||
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
|
||||
// es weder den einen noch die andere — der Satz stand am 28.08.2026
|
||||
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
|
||||
if (!betrieb.kann.externe_worker) return ''
|
||||
const ablage = settings.outputDir || betrieb.ablage_vorgabe
|
||||
const arbeit = settings.workDir
|
||||
if (!arbeit) {
|
||||
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
||||
@@ -381,16 +556,45 @@ export default function SettingsPage() {
|
||||
return ''
|
||||
})()
|
||||
|
||||
/*
|
||||
* Die Reiter haengen am BETRIEB.
|
||||
*
|
||||
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||||
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||||
*
|
||||
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
|
||||
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
|
||||
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
|
||||
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
|
||||
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
|
||||
* Bedienelemente, die ins Leere fuehren.
|
||||
*/
|
||||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||
{ id: 'worker', label: 'Worker', icon: Cpu },
|
||||
...(betrieb.kann.externe_worker
|
||||
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
|
||||
: []),
|
||||
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
|
||||
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
|
||||
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
|
||||
// das steckt weiter unten in einer eigenen Bedingung.
|
||||
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||||
{ id: 'apis', label: 'APIs', icon: Globe },
|
||||
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||||
{ id: 'system', label: 'System', icon: Wrench },
|
||||
]
|
||||
|
||||
/*
|
||||
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
|
||||
*
|
||||
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
|
||||
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
|
||||
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
|
||||
*/
|
||||
const sichtbareReiter = tabs.map(t => t.id)
|
||||
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<PageHeader
|
||||
@@ -409,7 +613,7 @@ export default function SettingsPage() {
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const isActive = aktiverReiter === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -430,7 +634,7 @@ export default function SettingsPage() {
|
||||
{/* Tab Content */}
|
||||
<CardContent className="p-6 space-y-8">
|
||||
{/* Ripping Settings */}
|
||||
{activeTab === 'ripping' && (
|
||||
{aktiverReiter === 'ripping' && (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
@@ -573,7 +777,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Verarbeitung */}
|
||||
{activeTab === 'verarbeitung' && (
|
||||
{aktiverReiter === 'verarbeitung' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Cpu size={20} className="text-amber-500" />
|
||||
@@ -637,28 +841,54 @@ export default function SettingsPage() {
|
||||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||||
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
||||
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
||||
|
||||
Seit 26.07.2026 kommen die NAMEN vom Worker selbst
|
||||
(`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
|
||||
Empfehlung von GET /presets. Vorher standen sie hier fest
|
||||
verdrahtet — sie unterscheiden sich aber je HandBrake-Version,
|
||||
und ein Name, den das HandBrake des Workers nicht kennt, lässt
|
||||
die Kompression scheitern.
|
||||
*/}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||||
4K-Auflösung ist weg.
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||||
4K-Auflösung ist weg.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={bestePresetsUebernehmen}
|
||||
disabled={!presetInfo}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Bestes wählen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Preset für 4K-UHD"
|
||||
value={settings.transcodePresetUhd}
|
||||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
||||
>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — 4K verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten</option>
|
||||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
||||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
||||
</Select>
|
||||
{presetInfo && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{presetInfo.quelle === 'worker' ? (
|
||||
<>
|
||||
Die Liste kommt von deinen Workern selbst.
|
||||
{presetInfo.hardware.length > 0
|
||||
? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
|
||||
: presetInfo.simd_schnell
|
||||
? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
|
||||
: ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 28–55 h je Film).'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
|
||||
keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{presetAuswahl('uhd', 'Preset für 4K-UHD',
|
||||
'Nicht komprimieren — 4K verlustfrei behalten')}
|
||||
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
||||
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
||||
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
||||
@@ -673,28 +903,10 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Preset für Blu-ray"
|
||||
value={settings.transcodePresetBluray}
|
||||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
||||
>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Preset für DVD"
|
||||
value={settings.transcodePresetDvd}
|
||||
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
||||
>
|
||||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||||
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
||||
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
||||
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
||||
</Select>
|
||||
{presetAuswahl('bluray', 'Preset für Blu-ray',
|
||||
'Nicht komprimieren — verlustfrei behalten')}
|
||||
{presetAuswahl('dvd', 'Preset für DVD',
|
||||
'Nicht komprimieren — verlustfrei behalten')}
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||||
@@ -703,6 +915,54 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
|
||||
muss „in der Automatik ebenfalls einstellbar" sein).
|
||||
|
||||
Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip
|
||||
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
|
||||
sich später mit anderer Sprachwahl neu komprimieren, ohne die
|
||||
Disc wieder einzulegen.
|
||||
*/}
|
||||
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
|
||||
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
|
||||
value={settings.audioSprachen}
|
||||
onChange={(e) => handleChange('audioSprachen', e.target.value)}
|
||||
placeholder="z. B. deu,eng,jpn"
|
||||
/>
|
||||
<Input
|
||||
label="Untertitel (ISO-639-2, mit Komma getrennt)"
|
||||
value={settings.untertitelSprachen}
|
||||
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
|
||||
placeholder="z. B. deu"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
|
||||
{' '}<span className="font-mono">eng</span> Englisch,
|
||||
{' '}<span className="font-mono">jpn</span> Japanisch,
|
||||
{' '}<span className="font-mono">fra</span> Französisch,
|
||||
{' '}<span className="font-mono">spa</span> Spanisch,
|
||||
{' '}<span className="font-mono">ita</span> Italienisch.
|
||||
Welche eine Disc wirklich hat, zeigt Rippy beim „Disc scannen" im
|
||||
Rip-Dialog — dort ist die Auswahl auch je Disc änderbar.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
|
||||
selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit
|
||||
neuem Rip möglich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<Toggle
|
||||
label="Original behalten"
|
||||
@@ -721,12 +981,41 @@ export default function SettingsPage() {
|
||||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||
inklusive freiem Platz.
|
||||
*/}
|
||||
{!betrieb.kann.container_pfade ? (
|
||||
/*
|
||||
* Ein echtes Pfadfeld statt einer Auswahl — dieselbe
|
||||
* Entscheidung wie bei der Ablage darüber, und aus demselben
|
||||
* Grund.
|
||||
*
|
||||
* Commander am 28.08.2026: „Wäre es möglich das
|
||||
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||||
*
|
||||
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
|
||||
* kommt aus /storage-targets, und das las `/app/media` — ein
|
||||
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
|
||||
* genau einem Eintrag, und der hieß „Container-Platte".
|
||||
*
|
||||
* Die Liste ist inzwischen repariert (sie bringt jetzt die
|
||||
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
|
||||
* „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
|
||||
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||
*/
|
||||
<OrdnerWaehler
|
||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||
value={settings.workDir}
|
||||
placeholder={betrieb.arbeits_vorgabe}
|
||||
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||
value={settings.workDir}
|
||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||
>
|
||||
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option>
|
||||
<option value="">
|
||||
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||
</option>
|
||||
{ziele.map(z => (
|
||||
<option key={z.path} value={z.path}>
|
||||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||||
@@ -734,13 +1023,35 @@ export default function SettingsPage() {
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{!betrieb.kann.container_pfade && !!ziele.length && (
|
||||
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
|
||||
wählt anders — genau daran ist am 25.07. eine Platte
|
||||
vollgelaufen. */
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ziele.map(z => (
|
||||
<button
|
||||
key={z.path}
|
||||
onClick={() => handleChange('workDir', z.path)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
settings.workDir === z.path
|
||||
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
{z.name}{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||||
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
|
||||
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
|
||||
Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||
{betrieb.kann.container_pfade
|
||||
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
|
||||
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
|
||||
{systemInfo?.plaetze?.length ? (
|
||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||
) : null}
|
||||
@@ -750,7 +1061,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Worker */}
|
||||
{activeTab === 'worker' && (
|
||||
{aktiverReiter === 'worker' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Cpu size={20} className="text-amber-500" />
|
||||
@@ -761,7 +1072,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Speicherziele */}
|
||||
{activeTab === 'speicherziele' && (
|
||||
{aktiverReiter === 'speicherziele' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<HardDrive size={20} className="text-amber-500" />
|
||||
@@ -781,6 +1092,19 @@ export default function SettingsPage() {
|
||||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
||||
</h3>
|
||||
{!betrieb.kann.container_pfade ? (
|
||||
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
|
||||
Windows-PC gibt es keine Liste von Container-Zielen, aus
|
||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||
Bedienung, die nichts bedient. */
|
||||
<OrdnerWaehler
|
||||
label="Ordner"
|
||||
value={settings.outputDir}
|
||||
placeholder={betrieb.ablage_vorgabe}
|
||||
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Ablage"
|
||||
value={settings.outputDir}
|
||||
@@ -794,6 +1118,7 @@ export default function SettingsPage() {
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
||||
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
||||
@@ -825,12 +1150,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorageMounts />
|
||||
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
|
||||
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
|
||||
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
|
||||
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* APIs */}
|
||||
{activeTab === 'apis' && (
|
||||
{aktiverReiter === 'apis' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Globe size={20} className="text-amber-500" />
|
||||
@@ -890,7 +1218,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Benachrichtigungen */}
|
||||
{activeTab === 'benachrichtigungen' && (
|
||||
{aktiverReiter === 'benachrichtigungen' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<AlertCircle size={20} className="text-amber-500" />
|
||||
@@ -940,7 +1268,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* System */}
|
||||
{activeTab === 'system' && (
|
||||
{aktiverReiter === 'system' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Wrench size={20} className="text-amber-500" />
|
||||
@@ -1008,13 +1336,44 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="MakeMKV Beta-Key"
|
||||
value={settings.makemkvAppKey}
|
||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||
/>
|
||||
{/*
|
||||
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||||
|
||||
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||||
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||||
glaube das web rippy kann das." Er hatte recht:
|
||||
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||||
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||||
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||||
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||||
Eine Automatik, die man nicht sehen kann, ist für den
|
||||
Benutzer keine.
|
||||
*/}
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
label="MakeMKV Beta-Key"
|
||||
value={settings.makemkvAppKey}
|
||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={keyHolt}
|
||||
onClick={keyJetztHolen}
|
||||
>
|
||||
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||||
</Button>
|
||||
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||||
+ 'der Key wechselt etwa monatlich.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||
|
||||
@@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY docker/worker/ .
|
||||
|
||||
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
|
||||
# neben den Worker-Modulen. Celery startet mit /app als Arbeitsverzeichnis,
|
||||
# damit findet "import rippy" das Paket ohne PYTHONPATH.
|
||||
COPY src/rippy ./rippy
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+153
-13
@@ -8,9 +8,50 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||
from rippy.tools import katalog as werkzeuge
|
||||
|
||||
|
||||
def _extern(werte=None, container=None) -> str:
|
||||
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||
|
||||
Drei Fälle, und nur einer davon ist „ja":
|
||||
|
||||
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||
etwas zu übersetzen wäre.
|
||||
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if container is None:
|
||||
container = betrieb.im_container()
|
||||
if container:
|
||||
return "nein"
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||
|
||||
|
||||
def _hb() -> str:
|
||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||
|
||||
Bis V2-4 stand hier `shutil.which("HandBrakeCLI")`. Das findet unter
|
||||
Windows nichts, auch wenn HandBrake installiert ist (Program Files statt
|
||||
PATH). Die Folge waere gewesen: Rippy meldet "keine Encoder gefunden" auf
|
||||
einem Rechner, auf dem alles da ist — und die Encoder-Auswahl im UI bliebe
|
||||
leer, ohne dass jemand den Grund saehe.
|
||||
"""
|
||||
return werkzeuge.finden("handbrake")
|
||||
|
||||
|
||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||
|
||||
@@ -106,6 +147,59 @@ def leite_backends_ab(hb_encoder) -> list:
|
||||
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 _hb():
|
||||
return []
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
[_hb(), "--preset-list"],
|
||||
capture_output=True, timeout=30, **HB_LESEN,
|
||||
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.
|
||||
|
||||
@@ -207,11 +301,12 @@ def cpu_merkmale() -> tuple:
|
||||
|
||||
def hole_handbrake_hilfe() -> str:
|
||||
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||
if not shutil.which("HandBrakeCLI"):
|
||||
if not _hb():
|
||||
return ""
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30
|
||||
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||
)
|
||||
return (aus.stdout or "") + (aus.stderr or "")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -274,8 +369,18 @@ def werkzeug_versionen() -> dict:
|
||||
# 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",
|
||||
#
|
||||
# ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
|
||||
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
|
||||
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
|
||||
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
|
||||
# Maschine, auf der es weder Container noch Freigabe gibt.
|
||||
#
|
||||
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
|
||||
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
|
||||
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
|
||||
# Ordnername.
|
||||
"extern": _extern(),
|
||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||
# grundsätzlich nicht komprimieren.
|
||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||
@@ -285,13 +390,22 @@ def werkzeug_versionen() -> dict:
|
||||
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
||||
# woher die Backend-Liste kommt.
|
||||
info["handbrake_encoder"] = ",".join(hb_encoder)
|
||||
if shutil.which("makemkvcon"):
|
||||
# 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 werkzeuge.finden("makemkv"):
|
||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||
if shutil.which("HandBrakeCLI"):
|
||||
if _hb():
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
[_hb(), "--version"],
|
||||
capture_output=True, timeout=15, **HB_LESEN,
|
||||
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"
|
||||
@@ -299,8 +413,8 @@ def werkzeug_versionen() -> dict:
|
||||
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()
|
||||
from rippy import store as db
|
||||
ui_key = (db.get_settings(bei_fehler_leer=True).get("makemkvAppKey") or "").strip()
|
||||
except Exception:
|
||||
ui_key = ""
|
||||
if ui_key:
|
||||
@@ -320,11 +434,25 @@ def werkzeug_versionen() -> dict:
|
||||
# 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
|
||||
from rippy.rip 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):
|
||||
# ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
|
||||
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
|
||||
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
|
||||
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
|
||||
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
|
||||
#
|
||||
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
|
||||
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
|
||||
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
|
||||
from rippy import betrieb
|
||||
|
||||
_im_container = betrieb.im_container()
|
||||
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
|
||||
else os.path.isdir(daten_dir))
|
||||
if werkzeuge.finden("makemkv") and daten_da:
|
||||
try:
|
||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||
except Exception:
|
||||
@@ -336,4 +464,16 @@ def werkzeug_versionen() -> dict:
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ celery_app.conf.update(
|
||||
# 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
|
||||
from rippy import store as db # noqa: E402
|
||||
import zombies # noqa: E402
|
||||
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
||||
|
||||
Die Tabellendefinition existiert bewusst identisch in API und Worker
|
||||
(docker/api/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
)
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
metadata = MetaData()
|
||||
|
||||
jobs = Table(
|
||||
"jobs",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("disc_type", String(16)),
|
||||
Column("device", String(64)),
|
||||
Column("title", String(255)),
|
||||
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)),
|
||||
)
|
||||
|
||||
logs = Table(
|
||||
"logs",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts", DateTime(timezone=True)),
|
||||
Column("level", String(16)),
|
||||
Column("source", String(32)),
|
||||
Column("message", Text),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""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 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 add_log(level: str, source: str, message: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||
)
|
||||
@@ -0,0 +1,760 @@
|
||||
import verwaltung
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
import webbrowser
|
||||
import re
|
||||
import flet as ft
|
||||
|
||||
# Tray-Icon: pystray + PIL fuer das Icon-Bild
|
||||
try:
|
||||
import pystray
|
||||
from PIL import Image as PILImage
|
||||
|
||||
TRAY_VERFUEGBAR = True
|
||||
except ImportError:
|
||||
TRAY_VERFUEGBAR = False
|
||||
|
||||
# Environment configuration
|
||||
BASIS = os.path.dirname(os.path.abspath(__file__))
|
||||
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") or os.getenv("RIPPY_HOST", "")
|
||||
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
||||
SLOTS = max(1, int(os.getenv("RIPPY_SLOTS", "1") or "1"))
|
||||
ABFRAGE_TAKT_SEKUNDEN = 4
|
||||
|
||||
|
||||
# Log file path
|
||||
def _log_pfad() -> str:
|
||||
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
||||
if basis_daten:
|
||||
ordner = os.path.join(basis_daten, "Rippy Worker")
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
return os.path.join(ordner, "worker.log")
|
||||
return os.path.join(BASIS, "worker.log")
|
||||
|
||||
|
||||
LOG_PFAD = _log_pfad()
|
||||
|
||||
# State variables
|
||||
prozess = None
|
||||
aktuell = {"job": None, "online": False}
|
||||
|
||||
# Windows Standby blocker
|
||||
_wach = {"an": False}
|
||||
|
||||
|
||||
def wach_halten(an: bool) -> None:
|
||||
if _wach["an"] == an:
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
flags = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) if an else ES_CONTINUOUS
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(flags)
|
||||
_wach["an"] = an
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Celery Subprocess Management
|
||||
def _leser(prozess_ref, datei, log_callback) -> None:
|
||||
try:
|
||||
for zeile in prozess_ref.stdout:
|
||||
try:
|
||||
datei.write(zeile)
|
||||
datei.flush()
|
||||
except OSError:
|
||||
pass
|
||||
if log_callback:
|
||||
log_callback(zeile)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
datei.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def worker_laeuft() -> bool:
|
||||
return prozess is not None and prozess.poll() is None
|
||||
|
||||
|
||||
def worker_starten(log_callback=None):
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
return
|
||||
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
||||
if not os.path.exists(celery):
|
||||
celery = "celery" # fallback to global if testing outside venv
|
||||
|
||||
befehl = [
|
||||
celery,
|
||||
"-A",
|
||||
"celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"-Q",
|
||||
"transcode",
|
||||
"-n",
|
||||
f"{WORKER_NAME}@%h",
|
||||
]
|
||||
befehl += (
|
||||
["--pool=solo"] if SLOTS == 1 else ["--pool=threads", f"--concurrency={SLOTS}"]
|
||||
)
|
||||
|
||||
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
||||
prozess = subprocess.Popen(
|
||||
befehl,
|
||||
cwd=BASIS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
errors="replace",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
threading.Thread(
|
||||
target=_leser,
|
||||
args=(prozess, log, log_callback),
|
||||
daemon=True,
|
||||
name="log-leser",
|
||||
).start()
|
||||
|
||||
|
||||
def worker_stoppen():
|
||||
global prozess
|
||||
if worker_laeuft():
|
||||
prozess.terminate()
|
||||
try:
|
||||
prozess.wait(15)
|
||||
except subprocess.TimeoutExpired:
|
||||
prozess.kill()
|
||||
prozess = None
|
||||
wach_halten(False)
|
||||
aktuell["job"] = None
|
||||
|
||||
|
||||
# API Polling
|
||||
def old_hole(pfad: str, host: str = None, timeout: int = 5):
|
||||
ziel = host if host is not None else RIPPY_HOST
|
||||
if not ziel:
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a:
|
||||
return json.load(a)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def hole_job_und_zustand():
|
||||
caps = verwaltung.hole("/capabilities")
|
||||
jobs = verwaltung.hole("/jobs") or []
|
||||
|
||||
online = False
|
||||
w_info = {}
|
||||
for w in (caps or {}).get("workers") or []:
|
||||
if w.get("name") == WORKER_NAME:
|
||||
online = bool(w.get("online"))
|
||||
w_info = w
|
||||
break
|
||||
|
||||
laufend = None
|
||||
for job in jobs if isinstance(jobs, list) else []:
|
||||
if job.get("status") in ("transcoding", "processing", "running"):
|
||||
laufend = job
|
||||
break
|
||||
|
||||
aktuell["online"] = online
|
||||
aktuell["job"] = laufend
|
||||
wach_halten(bool(laufend))
|
||||
return online, laufend, caps, jobs, w_info
|
||||
|
||||
|
||||
def deinstallieren() -> None:
|
||||
skript = os.path.join(BASIS, "uninstall.ps1")
|
||||
if not os.path.isfile(skript):
|
||||
return
|
||||
subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", skript],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
|
||||
def _bruecke_bauen():
|
||||
try:
|
||||
from rippy import store as db
|
||||
import logbruecke
|
||||
|
||||
db.init_db()
|
||||
return logbruecke.Bruecke(
|
||||
WORKER_NAME,
|
||||
schreiber=lambda level, quelle, text: db.add_log(level, quelle, text),
|
||||
jetzt=time.monotonic,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _schluessel_wache(bruecke=None, log_callback=None):
|
||||
try:
|
||||
import schluessel
|
||||
|
||||
if not schluessel.makemkvcon_pfad():
|
||||
return
|
||||
|
||||
def melden(level, text):
|
||||
msg = f"[schluessel] {level.upper()} {text}"
|
||||
if bruecke:
|
||||
bruecke.zeile(msg)
|
||||
if log_callback:
|
||||
log_callback(msg + "\n")
|
||||
|
||||
stand = None
|
||||
while True:
|
||||
try:
|
||||
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
|
||||
except Exception as e:
|
||||
if log_callback:
|
||||
log_callback(
|
||||
f"[schluessel] ERROR Schlüssel-Automatik fehlgeschlagen: {e}\n"
|
||||
)
|
||||
time.sleep(schluessel.TAKT_SEKUNDEN)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# GUI Application
|
||||
def main(page: ft.Page):
|
||||
page.title = f"Rippy Worker — {WORKER_NAME}"
|
||||
page.window.width = 900
|
||||
page.window.height = 700
|
||||
page.bgcolor = "#0f172a" # Rippy Slate-900 background
|
||||
page.padding = 30
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.fonts = {
|
||||
"Inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
}
|
||||
page.theme = ft.Theme(font_family="Inter")
|
||||
|
||||
# --- Tray-Icon -----------------------------------------------------------
|
||||
tray_icon_ref = {"icon": None}
|
||||
|
||||
def _tray_icon_bild():
|
||||
"""Lade rippy.ico oder erzeuge ein Fallback-Bild."""
|
||||
ico_pfad = os.path.join(BASIS, "rippy.ico")
|
||||
if os.path.isfile(ico_pfad):
|
||||
try:
|
||||
return PILImage.open(ico_pfad)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: kleines lila Quadrat
|
||||
img = PILImage.new("RGB", (64, 64), (99, 102, 241))
|
||||
return img
|
||||
|
||||
def tray_zeigen(icon, item):
|
||||
"""Tray-Menue: Fenster wieder anzeigen."""
|
||||
page.window.visible = True
|
||||
page.window.minimized = False
|
||||
page.update()
|
||||
|
||||
def tray_beenden(icon, item):
|
||||
"""Tray-Menue: App komplett beenden."""
|
||||
if tray_icon_ref["icon"]:
|
||||
tray_icon_ref["icon"].stop()
|
||||
worker_stoppen()
|
||||
page.window.destroy()
|
||||
|
||||
def tray_starten():
|
||||
"""Erstellt und startet das System-Tray-Icon."""
|
||||
if not TRAY_VERFUEGBAR:
|
||||
return
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Rippy Worker anzeigen", tray_zeigen, default=True),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Beenden", tray_beenden),
|
||||
)
|
||||
icon = pystray.Icon(
|
||||
"rippy_worker", _tray_icon_bild(), f"Rippy Worker — {WORKER_NAME}", menu
|
||||
)
|
||||
tray_icon_ref["icon"] = icon
|
||||
threading.Thread(target=icon.run, daemon=True, name="tray-icon").start()
|
||||
|
||||
def on_window_event(e):
|
||||
"""Fenster-Schliessen minimiert ins Tray statt die App zu beenden."""
|
||||
if e.data == "close":
|
||||
if TRAY_VERFUEGBAR and tray_icon_ref["icon"]:
|
||||
page.window.visible = False
|
||||
page.update()
|
||||
else:
|
||||
# Kein Tray verfuegbar — wirklich beenden
|
||||
worker_stoppen()
|
||||
page.window.destroy()
|
||||
|
||||
page.window.prevent_close = True
|
||||
page.window.on_event = on_window_event
|
||||
tray_starten()
|
||||
|
||||
# Colors
|
||||
|
||||
c_panel = "#1e293b"
|
||||
c_text = "#e2e8f0"
|
||||
c_muted = "#94a3b8"
|
||||
c_accent = "#6366f1" # Indigo
|
||||
c_success = "#10b981" # Emerald
|
||||
c_warning = "#f59e0b" # Amber
|
||||
c_danger = "#f43f5e" # Rose
|
||||
|
||||
# Formatter for job status
|
||||
def old_job_text(job: dict) -> str:
|
||||
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
|
||||
status = {
|
||||
"transcoding": "komprimiert",
|
||||
"processing": "rippt",
|
||||
"running": "rippt",
|
||||
"pending": "wartet",
|
||||
"completed": "fertig",
|
||||
"failed": "Fehler",
|
||||
"canceling": "bricht ab",
|
||||
}.get(job.get("status") or "", job.get("status") or "?")
|
||||
return f"{titel} — {status}"
|
||||
|
||||
# UI Components
|
||||
status_text = ft.Text(
|
||||
"Verbinde...", color=c_muted, size=18, weight=ft.FontWeight.BOLD
|
||||
)
|
||||
title_text = ft.Text(
|
||||
f"Rippy Worker: {WORKER_NAME}", size=24, weight=ft.FontWeight.BOLD, color=c_text
|
||||
)
|
||||
machine_info = ft.Text(
|
||||
"System-Infos werden geladen...", color=c_muted, size=13, font_family="Consolas"
|
||||
)
|
||||
|
||||
progress_ring = ft.ProgressRing(
|
||||
width=120, height=120, stroke_width=10, value=0, color=c_accent
|
||||
)
|
||||
progress_percentage = ft.Text(
|
||||
"0%", size=28, weight=ft.FontWeight.BOLD, color=c_text
|
||||
)
|
||||
job_title = ft.Text("Nichts in Arbeit", size=20, color=c_muted)
|
||||
eta_text = ft.Text("Bereit", size=14, color=c_muted)
|
||||
|
||||
progress_stack = ft.Stack(
|
||||
[
|
||||
progress_ring,
|
||||
ft.Container(
|
||||
content=progress_percentage,
|
||||
alignment=ft.alignment.center,
|
||||
width=120,
|
||||
height=120,
|
||||
),
|
||||
],
|
||||
width=120,
|
||||
height=120,
|
||||
)
|
||||
|
||||
# Live Log Regex and Layout
|
||||
# [2026-07-26 20:34:00,093: INFO/MainProcess] message...
|
||||
LOG_REGEX = re.compile(
|
||||
r"^\[.*? (\d{2}:\d{2}:\d{2}),.*?: (INFO|WARNING|ERROR|SUCCESS).*?\] (.*)"
|
||||
)
|
||||
|
||||
log_view = ft.ListView(expand=1, spacing=0, auto_scroll=True)
|
||||
queue_view = ft.ListView(height=120, spacing=4, auto_scroll=False)
|
||||
|
||||
def add_log(zeile):
|
||||
z = zeile.strip()
|
||||
if not z:
|
||||
return
|
||||
|
||||
match = LOG_REGEX.match(z)
|
||||
if match:
|
||||
zeit, level, msg = match.groups()
|
||||
color = c_text
|
||||
if level == "INFO":
|
||||
color = "#38bdf8" # sky-400
|
||||
elif level == "WARNING":
|
||||
color = "#fbbf24" # amber-400
|
||||
elif level == "ERROR":
|
||||
color = "#f87171" # red-400
|
||||
elif level == "SUCCESS":
|
||||
color = "#34d399" # emerald-400
|
||||
|
||||
row = ft.Row(
|
||||
[
|
||||
ft.Text(zeit, color=c_muted, size=12, font_family="Consolas"),
|
||||
ft.Text(
|
||||
f"[{level}]",
|
||||
color=color,
|
||||
size=12,
|
||||
font_family="Consolas",
|
||||
weight=ft.FontWeight.BOLD,
|
||||
),
|
||||
ft.Text(
|
||||
msg, color=c_text, size=12, font_family="Consolas", expand=True, selectable=True
|
||||
),
|
||||
],
|
||||
spacing=10,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||
)
|
||||
log_view.controls.append(row)
|
||||
else:
|
||||
log_view.controls.append(
|
||||
ft.Text(z, color=c_muted, size=12, font_family="Consolas", selectable=True)
|
||||
)
|
||||
|
||||
if len(log_view.controls) > 200:
|
||||
log_view.controls.pop(0)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def toggle_worker(e):
|
||||
if not worker_laeuft():
|
||||
worker_starten(add_log)
|
||||
btn_toggle.visible = False
|
||||
page.update()
|
||||
|
||||
btn_toggle = ft.ElevatedButton(
|
||||
text="Worker läuft" if worker_laeuft() else "Worker Starten",
|
||||
icon=ft.icons.CHECK if worker_laeuft() else ft.icons.PLAY_ARROW,
|
||||
bgcolor="#1e293b" if worker_laeuft() else c_success,
|
||||
color="white",
|
||||
disabled=worker_laeuft(),
|
||||
on_click=toggle_worker,
|
||||
)
|
||||
|
||||
def uninstall_click(e):
|
||||
def close_dlg(e):
|
||||
dlg.open = False
|
||||
page.update()
|
||||
|
||||
def do_uninstall(e):
|
||||
deinstallieren()
|
||||
page.window.close()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text("Deinstallieren?"),
|
||||
content=ft.Text(
|
||||
f"Worker '{WORKER_NAME}' wirklich entfernen? Das beendet den Prozess und entfernt ihn aus dem Autostart."
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton("Abbrechen", on_click=close_dlg),
|
||||
ft.TextButton(
|
||||
"Ja, Deinstallieren",
|
||||
on_click=do_uninstall,
|
||||
style=ft.ButtonStyle(color=c_danger),
|
||||
),
|
||||
],
|
||||
actions_alignment=ft.MainAxisAlignment.END,
|
||||
)
|
||||
page.overlay.append(dlg)
|
||||
dlg.open = True
|
||||
page.update()
|
||||
|
||||
btn_uninstall = ft.TextButton(
|
||||
text="Deinstallieren",
|
||||
icon=ft.icons.DELETE,
|
||||
icon_color=c_danger,
|
||||
on_click=uninstall_click,
|
||||
)
|
||||
|
||||
btn_dashboard = ft.TextButton(
|
||||
text="Rippy Dashboard",
|
||||
icon=ft.icons.OPEN_IN_BROWSER,
|
||||
on_click=lambda _: (
|
||||
webbrowser.open(f"http://{RIPPY_HOST}") if RIPPY_HOST else None
|
||||
),
|
||||
)
|
||||
|
||||
btn_logs = ft.TextButton(
|
||||
text="Log in Rippy",
|
||||
icon=ft.icons.FORMAT_ALIGN_LEFT,
|
||||
on_click=lambda _: (
|
||||
webbrowser.open(f"http://{RIPPY_HOST}/logs") if RIPPY_HOST else None
|
||||
),
|
||||
)
|
||||
|
||||
btn_local_log = ft.TextButton(
|
||||
text="Lokales Log",
|
||||
icon=ft.icons.INSERT_DRIVE_FILE,
|
||||
on_click=lambda _: os.startfile(LOG_PFAD) if os.path.exists(LOG_PFAD) else None,
|
||||
)
|
||||
|
||||
# Layout
|
||||
header = ft.Row(
|
||||
[
|
||||
title_text,
|
||||
ft.Container(expand=True),
|
||||
btn_dashboard,
|
||||
btn_logs,
|
||||
btn_local_log,
|
||||
btn_uninstall,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
)
|
||||
|
||||
active_poster = ft.Image(
|
||||
src="",
|
||||
visible=False,
|
||||
width=72,
|
||||
height=108,
|
||||
border_radius=10,
|
||||
fit=ft.ImageFit.COVER,
|
||||
)
|
||||
active_poster_container = ft.Container(
|
||||
content=active_poster,
|
||||
border=ft.border.all(1, c_warning),
|
||||
border_radius=10,
|
||||
shadow=ft.BoxShadow(spread_radius=1, blur_radius=5, color=ft.colors.with_opacity(0.3, ft.colors.BLACK)),
|
||||
visible=False
|
||||
)
|
||||
|
||||
dashboard_card = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
status_text,
|
||||
machine_info,
|
||||
ft.Container(height=5),
|
||||
ft.Row(
|
||||
[
|
||||
active_poster_container,
|
||||
progress_stack,
|
||||
ft.Column(
|
||||
[job_title, eta_text],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=10,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=30,
|
||||
),
|
||||
ft.Container(height=10),
|
||||
btn_toggle,
|
||||
]
|
||||
),
|
||||
bgcolor=c_panel,
|
||||
border_radius=15,
|
||||
padding=30,
|
||||
border=ft.border.all(1, "#334155"),
|
||||
)
|
||||
|
||||
queue_card = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(
|
||||
"Aufgaben (Warteschlange)",
|
||||
size=16,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=c_text,
|
||||
),
|
||||
queue_view,
|
||||
]
|
||||
),
|
||||
bgcolor="#020617",
|
||||
border_radius=10,
|
||||
padding=15,
|
||||
border=ft.border.all(1, "#1e293b"),
|
||||
)
|
||||
|
||||
terminal_card = ft.Container(
|
||||
content=log_view,
|
||||
bgcolor="#020617",
|
||||
border_radius=10,
|
||||
padding=15,
|
||||
expand=True,
|
||||
border=ft.border.all(1, "#1e293b"),
|
||||
)
|
||||
|
||||
page.add(
|
||||
header,
|
||||
ft.Container(height=10),
|
||||
dashboard_card,
|
||||
ft.Container(height=10),
|
||||
queue_card,
|
||||
ft.Container(height=10),
|
||||
ft.Text("Live Logs", size=16, weight=ft.FontWeight.BOLD, color=c_text),
|
||||
terminal_card,
|
||||
)
|
||||
|
||||
# Background polling loop
|
||||
def poll_loop():
|
||||
while True:
|
||||
try:
|
||||
online, job, caps, jobs, w_info = hole_job_und_zustand()
|
||||
|
||||
# Update System Info
|
||||
info = w_info.get("info") or {}
|
||||
teile = [
|
||||
t
|
||||
for t in (
|
||||
info.get("cpu_modell"),
|
||||
f"{info.get('cpu_kerne')} Kerne"
|
||||
if info.get("cpu_kerne")
|
||||
else "",
|
||||
info.get("cpu_simd")
|
||||
if info.get("cpu_simd") not in ("", "unbekannt")
|
||||
else "",
|
||||
", ".join(w_info.get("encoders") or []),
|
||||
)
|
||||
if t
|
||||
]
|
||||
machine_info.value = (
|
||||
" · ".join(teile)
|
||||
if teile
|
||||
else f"{SLOTS} Aufträge gleichzeitig möglich"
|
||||
)
|
||||
|
||||
# Update Queue
|
||||
queue_view.controls.clear()
|
||||
if not jobs:
|
||||
queue_view.controls.append(
|
||||
ft.Text(
|
||||
"Keine Aufgaben in Rippy",
|
||||
color=c_muted,
|
||||
font_family="Consolas",
|
||||
size=13,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for j in jobs[:8]:
|
||||
poster = None
|
||||
if j.get("meta") and j["meta"].get("poster_path"):
|
||||
p = j["meta"]["poster_path"]
|
||||
poster = (
|
||||
p
|
||||
if p.startswith("http")
|
||||
else f"https://image.tmdb.org/t/p/w342{p}"
|
||||
)
|
||||
|
||||
row_items = []
|
||||
if poster:
|
||||
row_items.append(
|
||||
ft.Container(
|
||||
content=ft.Image(
|
||||
src=poster,
|
||||
width=32,
|
||||
height=48,
|
||||
border_radius=6,
|
||||
fit=ft.ImageFit.COVER,
|
||||
),
|
||||
border=ft.border.all(1, "#334155"),
|
||||
border_radius=6,
|
||||
shadow=ft.BoxShadow(spread_radius=1, blur_radius=3, color=ft.colors.with_opacity(0.3, ft.colors.BLACK))
|
||||
)
|
||||
)
|
||||
row_items.append(
|
||||
ft.Text(
|
||||
verwaltung.job_text(j).split(",")[0].split(",")[0],
|
||||
color=c_muted,
|
||||
font_family="Consolas",
|
||||
size=13,
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
queue_view.controls.append(
|
||||
ft.Row(
|
||||
row_items,
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=15,
|
||||
)
|
||||
)
|
||||
|
||||
# Update status
|
||||
if not worker_laeuft():
|
||||
status_text.value = "Worker-Prozess gestoppt"
|
||||
status_text.color = c_danger
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
job_title.value = "Nichts in Arbeit"
|
||||
eta_text.value = "Bereit"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = True
|
||||
btn_toggle.disabled = False
|
||||
btn_toggle.text = "Worker Starten"
|
||||
btn_toggle.icon = ft.icons.PLAY_ARROW
|
||||
btn_toggle.bgcolor = c_success
|
||||
elif not online:
|
||||
status_text.value = "Nicht mit Rippy verbunden (offline)"
|
||||
status_text.color = c_warning
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = True
|
||||
btn_toggle.disabled = True
|
||||
btn_toggle.text = "Warte auf Verbindung..."
|
||||
btn_toggle.bgcolor = "#1e293b"
|
||||
elif job:
|
||||
status_text.value = "Aktiv: Komprimiert"
|
||||
status_text.color = c_success
|
||||
prog = float(job.get("progress") or 0)
|
||||
progress_ring.value = prog / 100.0
|
||||
progress_percentage.value = f"{int(prog)}%"
|
||||
titel = job.get("title") or (job.get("id") or "")[:8]
|
||||
job_title.value = titel
|
||||
eta = job.get("eta_text") or "Restzeit wird berechnet..."
|
||||
eta_text.value = f"Verbleibend: {eta}"
|
||||
|
||||
# Update Active Poster
|
||||
poster = None
|
||||
if job.get("meta") and job["meta"].get("poster_path"):
|
||||
p = job["meta"]["poster_path"]
|
||||
poster = (
|
||||
p
|
||||
if p.startswith("http")
|
||||
else f"https://image.tmdb.org/t/p/w342{p}"
|
||||
)
|
||||
|
||||
if poster:
|
||||
active_poster.src = poster
|
||||
active_poster_container.visible = True
|
||||
else:
|
||||
active_poster_container.visible = False
|
||||
|
||||
btn_toggle.visible = False
|
||||
else:
|
||||
status_text.value = "Verbunden & Bereit"
|
||||
status_text.color = c_accent
|
||||
progress_ring.value = 0
|
||||
progress_percentage.value = "0%"
|
||||
job_title.value = "Warte auf Encoder-Aufträge..."
|
||||
eta_text.value = "Nichts in Arbeit"
|
||||
active_poster_container.visible = False
|
||||
btn_toggle.visible = False
|
||||
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(ABFRAGE_TAKT_SEKUNDEN)
|
||||
|
||||
# Start Celery automatically
|
||||
worker_starten(add_log)
|
||||
|
||||
# Init initial logs
|
||||
if os.path.exists(LOG_PFAD):
|
||||
try:
|
||||
with open(LOG_PFAD, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()[-50:]
|
||||
for line in lines:
|
||||
add_log(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
threading.Thread(target=poll_loop, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_schluessel_wache,
|
||||
args=(_bruecke_bauen(), add_log),
|
||||
daemon=True,
|
||||
name="schluessel-wache",
|
||||
).start()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.app(target=main)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Celery-Ausgabe des Workers nach Rippy melden — statt in eine txt-Datei.
|
||||
|
||||
Commander-Wunsch 26.07.2026: *„bessere Log-Ansichten (kein txt file → direkt von
|
||||
Rippy Logs)"*. Der native Windows-Worker schrieb sein Log bisher nach
|
||||
`%LOCALAPPDATA%\\Rippy Worker\\worker.log`, und das Tray öffnete die Datei im
|
||||
Editor. Wer wissen wollte, warum der Worker nichts tut, musste sich also an den
|
||||
PC setzen — während Rippy im Browser schon eine Log-Seite hat.
|
||||
|
||||
## Was hier durchgeht — und warum so wenig
|
||||
|
||||
Die JOB-Meldungen stehen längst in Rippy: `tasks.py` schreibt „Kompression
|
||||
gestartet", Fehlschläge, Abbrüche selbst über `db.add_log`. Was fehlte, ist
|
||||
alles, was DANEBEN passiert und den Worker unbrauchbar macht, ohne dass ein Job
|
||||
existiert:
|
||||
|
||||
- Der Worker ist hochgefahren (oder eben nicht).
|
||||
- Die Verbindung zu Redis/Postgres steht nicht.
|
||||
- Er ist abgestürzt (Traceback).
|
||||
|
||||
Alles andere wird verworfen. Celery ist bei `--loglevel=info` gesprächig, die
|
||||
`logs`-Tabelle hat keine automatische Aufräumung, und ein zugemülltes Log ist
|
||||
genauso unbrauchbar wie keins.
|
||||
|
||||
## Das Format ist gemessen, nicht geraten
|
||||
|
||||
Wörtlich aus dem laufenden Worker-Container (26.07.2026, Celery 5.4.0) —
|
||||
AGENTS Regel D:
|
||||
|
||||
[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0
|
||||
[2026-07-26 12:04:19,063: INFO/MainProcess] mingle: searching for neighbors
|
||||
[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready.
|
||||
[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: {...}
|
||||
|
||||
Dazu der Startbanner (Zeilen ohne diese Klammer) und Warnungen aus Pythons
|
||||
warnings-Modul.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# `[<Zeitstempel>: <LEVEL>/<Prozess>] <Text>`
|
||||
CELERY_ZEILE = re.compile(r"^\[[^\]]*?:\s*(\w+)/([^\]]+)\]\s*(.*)$")
|
||||
|
||||
# Celery-Level → Rippy-Level. DEBUG fällt bewusst weg.
|
||||
LEVEL_MAP = {
|
||||
"CRITICAL": "error",
|
||||
"ERROR": "error",
|
||||
"WARNING": "warning",
|
||||
"INFO": "info",
|
||||
}
|
||||
|
||||
# INFO-Zeilen sind nur interessant, wenn sie einen ZUSTAND melden. Diese Stücke
|
||||
# sind alle oben im echten Log nachweisbar.
|
||||
INFO_DURCHLASSEN = ("ready.", "Connected to", "Trying again", "connection")
|
||||
|
||||
# ... und diese sind reines Grundrauschen.
|
||||
INFO_VERWERFEN = ("mingle:", "task events", "searching for neighbors", "all alone")
|
||||
|
||||
# Zeilen OHNE Celery-Klammer (Startbanner, Python-warnings) kommen nur durch,
|
||||
# wenn sie nach einem echten Problem aussehen.
|
||||
ROHE_FEHLER = ("Traceback", "Error:", "error(", "Errno", "refused", "Cannot")
|
||||
|
||||
# Schutz für die logs-Tabelle: mehr als das pro Minute ist kein Log, das ist ein
|
||||
# Wasserfall.
|
||||
MAX_ZEILEN_JE_MINUTE = 30
|
||||
|
||||
|
||||
def einordnen(zeile: str):
|
||||
"""(level, text) für eine Ausgabezeile — oder None, wenn sie nichts wert ist.
|
||||
|
||||
Reine Funktion, damit das Urteil ohne laufenden Worker prüfbar ist.
|
||||
"""
|
||||
text = (zeile or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
treffer = CELERY_ZEILE.match(text)
|
||||
if not treffer:
|
||||
# Startbanner und warnings-Ausgaben: nur echte Probleme.
|
||||
if any(s in text for s in ROHE_FEHLER):
|
||||
return ("error", text[:500])
|
||||
return None
|
||||
celery_level, _prozess, inhalt = treffer.groups()
|
||||
level = LEVEL_MAP.get(celery_level.upper())
|
||||
if not level or not inhalt:
|
||||
return None
|
||||
if level == "info":
|
||||
if any(s in inhalt for s in INFO_VERWERFEN):
|
||||
return None
|
||||
if not any(s in inhalt for s in INFO_DURCHLASSEN):
|
||||
return None
|
||||
# „ready." heißt: der Worker nimmt jetzt Aufträge an. Das ist die
|
||||
# Meldung, auf die man wartet, wenn nichts passiert.
|
||||
if "ready." in inhalt:
|
||||
return ("success", inhalt[:500])
|
||||
return (level, inhalt[:500])
|
||||
|
||||
|
||||
def quelle_fuer(worker_name: str) -> str:
|
||||
"""Log-Quelle für diesen Worker.
|
||||
|
||||
Gekappt auf 32 Zeichen, weil `logs.source` genau so breit ist (db.py). Ohne
|
||||
das Kappen scheitert das Insert still bei einem langen Worker-Namen — und
|
||||
dann fehlt genau das Log, das man sucht.
|
||||
"""
|
||||
name = (worker_name or "worker").strip() or "worker"
|
||||
return f"w:{name}"[:32]
|
||||
|
||||
|
||||
class Bruecke:
|
||||
"""Nimmt Ausgabezeilen und schreibt die wichtigen nach Rippy.
|
||||
|
||||
`schreiber(level, quelle, text)` und `jetzt()` sind einspritzbar — so ist
|
||||
die Drosselung ohne Datenbank und ohne echte Uhr prüfbar.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_name: str, schreiber, jetzt):
|
||||
self.quelle = quelle_fuer(worker_name)
|
||||
self.schreiber = schreiber
|
||||
self.jetzt = jetzt
|
||||
self._fenster_start = None
|
||||
self._im_fenster = 0
|
||||
self._unterdrueckt = 0
|
||||
|
||||
def zeile(self, text: str) -> bool:
|
||||
"""Eine Ausgabezeile verarbeiten. True, wenn sie nach Rippy ging."""
|
||||
eingeordnet = einordnen(text)
|
||||
if not eingeordnet:
|
||||
return False
|
||||
level, inhalt = eingeordnet
|
||||
|
||||
jetzt = self.jetzt()
|
||||
if self._fenster_start is None or jetzt - self._fenster_start >= 60:
|
||||
# Neues Fenster: erst melden, wieviel im alten verschluckt wurde.
|
||||
if self._unterdrueckt:
|
||||
self._melden(
|
||||
"warning",
|
||||
f"{self._unterdrueckt} weitere Log-Zeilen unterdrückt "
|
||||
"(mehr als 30 pro Minute) — Details im Worker-Log auf der "
|
||||
"Maschine selbst.",
|
||||
)
|
||||
self._fenster_start = jetzt
|
||||
self._im_fenster = 0
|
||||
self._unterdrueckt = 0
|
||||
|
||||
if self._im_fenster >= MAX_ZEILEN_JE_MINUTE:
|
||||
self._unterdrueckt += 1
|
||||
return False
|
||||
self._im_fenster += 1
|
||||
return self._melden(level, inhalt)
|
||||
|
||||
def _melden(self, level: str, text: str) -> bool:
|
||||
try:
|
||||
self.schreiber(level, self.quelle, text)
|
||||
return True
|
||||
except Exception:
|
||||
# Rippy nicht erreichbar → die lokale Datei bleibt der Rückfall.
|
||||
# Hier NICHTS werfen: der Worker soll weiterarbeiten.
|
||||
return False
|
||||
@@ -1,375 +0,0 @@
|
||||
"""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 ""
|
||||
+30
-11
@@ -140,24 +140,43 @@ def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list)
|
||||
|
||||
|
||||
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
|
||||
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby).
|
||||
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby/Kodi).
|
||||
|
||||
API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh,
|
||||
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
|
||||
Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token.
|
||||
Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass).
|
||||
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
||||
"""
|
||||
if media_server not in ("jellyfin", "emby") or not url or not api_key:
|
||||
if not url or media_server not in ("jellyfin", "emby", "kodi"):
|
||||
return ""
|
||||
try:
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/Library/Refresh",
|
||||
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"{media_server}-Bibliothek aktualisiert ({url})"
|
||||
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
if media_server in ("jellyfin", "emby") and api_key:
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/Library/Refresh",
|
||||
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"{media_server.title()}-Bibliothek aktualisiert ({url})"
|
||||
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
|
||||
elif media_server == "kodi":
|
||||
auth = None
|
||||
if api_key and ":" in api_key:
|
||||
user, pw = api_key.split(":", 1)
|
||||
auth = (user, pw)
|
||||
|
||||
payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1}
|
||||
antwort = requests.post(
|
||||
url.rstrip("/") + "/jsonrpc",
|
||||
json=payload, auth=auth, timeout=15,
|
||||
)
|
||||
if antwort.status_code < 300:
|
||||
return f"Kodi-Bibliothek aktualisiert ({url})"
|
||||
return f"Kodi-Refresh: HTTP {antwort.status_code} von {url}"
|
||||
|
||||
except requests.RequestException as e:
|
||||
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||
|
||||
@@ -3,3 +3,4 @@ redis==5.0.4
|
||||
requests==2.32.3
|
||||
sqlalchemy==2.0.32
|
||||
psycopg2-binary==2.9.9
|
||||
flet==0.23.2
|
||||
|
||||
+648
-66
@@ -17,6 +17,55 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
|
||||
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
|
||||
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
|
||||
#
|
||||
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
|
||||
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
|
||||
from rippy.drives.linux import ( # noqa: F401
|
||||
AUSWURF_WARTEN_SEKUNDEN,
|
||||
CDROM_DRIVE_STATUS,
|
||||
CDROM_LOCKDOOR,
|
||||
CDROMCLOSETRAY,
|
||||
CDROMEJECT,
|
||||
CDS_DISC_OK,
|
||||
CDS_DRIVE_NOT_READY,
|
||||
CDS_NO_DISC,
|
||||
CDS_TRAY_OPEN,
|
||||
_auswurf_geglueckt,
|
||||
)
|
||||
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||
from rippy.tools import katalog as werkzeuge
|
||||
|
||||
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||
|
||||
|
||||
def werkzeug(name: str) -> str:
|
||||
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
|
||||
|
||||
## Warum hier nicht mehr `shutil.which` steht
|
||||
|
||||
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
|
||||
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das — dort liegen
|
||||
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
|
||||
beide installiert sind: Windows-Programme liegen in Program Files und
|
||||
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen —
|
||||
MakeMKV lag da, und `shutil.which` sah es nicht.
|
||||
|
||||
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
|
||||
der Unterschied zwischen "laeuft" und "laeuft nicht".
|
||||
|
||||
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
|
||||
damit NICHTS — subprocess findet das Programm dort ueber den PATH wie
|
||||
bisher.
|
||||
"""
|
||||
return werkzeuge.finden(name) or _NACKTER_NAME[name]
|
||||
|
||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||
|
||||
|
||||
@@ -25,39 +74,9 @@ class RipAbbruch(Exception):
|
||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||
|
||||
|
||||
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
|
||||
CDROMEJECT = 0x5309
|
||||
|
||||
|
||||
def wirf_disc_aus(device_path: str) -> bool:
|
||||
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
|
||||
|
||||
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
|
||||
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
|
||||
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
|
||||
|
||||
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
|
||||
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
|
||||
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
|
||||
"""
|
||||
try:
|
||||
from fcntl import ioctl
|
||||
except ImportError: # Windows
|
||||
return False
|
||||
try:
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_makemkv_installed() -> bool:
|
||||
"""Prüft, ob makemkvcon installiert ist."""
|
||||
return shutil.which("makemkvcon") is not None
|
||||
return bool(werkzeuge.finden("makemkv"))
|
||||
|
||||
|
||||
def check_abcde_installed() -> bool:
|
||||
@@ -70,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
||||
return shutil.which("cdparanoia") is not None
|
||||
|
||||
|
||||
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||
#:
|
||||
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||
#: deutschsprachiges MakeMKV):
|
||||
#:
|
||||
#: Encountered 29 errors of type 'Read Error' - see
|
||||
#: http://www.makemkv.com/errors/read/
|
||||
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||
#:
|
||||
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||
#: hierher statt dieser Textsuche.
|
||||
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||
|
||||
|
||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||
|
||||
@@ -81,12 +118,12 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
||||
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||
"""
|
||||
return [
|
||||
"makemkvcon",
|
||||
werkzeug("makemkv"),
|
||||
"-r",
|
||||
"--noscan",
|
||||
"--progress=-same",
|
||||
"mkv",
|
||||
f"dev:{device_path}",
|
||||
makemkv_quelle(device_path),
|
||||
str(titel),
|
||||
output_dir,
|
||||
]
|
||||
@@ -134,13 +171,119 @@ def parse_titel_info(ausgabe: str) -> list:
|
||||
return [titel[nr] for nr in sorted(titel)]
|
||||
|
||||
|
||||
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
||||
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
||||
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
|
||||
# der Rippy-VM abgenommen (AGENTS Regel D):
|
||||
#
|
||||
# SINFO:0,1,1,6202,"Audio"
|
||||
# SINFO:0,1,3,0,"deu"
|
||||
# SINFO:0,1,4,0,"German"
|
||||
# SINFO:0,1,6,0,"DTS-HD MA"
|
||||
# SINFO:0,1,14,0,"2"
|
||||
# SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||
#
|
||||
# ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29. Die tragen auf JEDEM Stream
|
||||
# „eng"/„English", auch auf dem Videostream einer deutschen Tonspur; das ist
|
||||
# MakeMKVs eigene Anzeigesprache und nicht die des Tracks. Wer 28 nimmt, hält
|
||||
# jede Disc für englisch.
|
||||
SINFO_TYP = 1
|
||||
SINFO_SPRACHE_CODE = 3
|
||||
SINFO_SPRACHE_NAME = 4
|
||||
SINFO_CODEC = 6
|
||||
SINFO_KANAELE = 14
|
||||
SINFO_BESCHREIBUNG = 30
|
||||
|
||||
SINFO_ZEILE = re.compile(r'SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$')
|
||||
|
||||
|
||||
def parse_stream_info(ausgabe: str) -> dict:
|
||||
"""SINFO-Zeilen → Tonspuren und Untertitel je Titel (pure Funktion).
|
||||
|
||||
Commander-Anforderung 26.07.2026: *„Es muss verfügbar sein, dass man die
|
||||
Sprache von Medien auswählen kann … Rippy muss VOR dem Rip fragen: Was genau
|
||||
willst du haben?"* Die Auskunft liegt längst vor — der Titel-Scan liest sie
|
||||
mit und hat sie bisher weggeworfen.
|
||||
|
||||
Rückgabe: {titel_nr: {"audio": [...], "untertitel": [...]}}, jeder Eintrag
|
||||
{"nr", "lang", "sprache", "codec", "kanaele", "beschreibung"}.
|
||||
"""
|
||||
titel: dict = {}
|
||||
for zeile in (ausgabe or "").splitlines():
|
||||
treffer = SINFO_ZEILE.match(zeile.strip())
|
||||
if not treffer:
|
||||
continue
|
||||
t_nr, s_nr, attr = (int(treffer.group(i)) for i in (1, 2, 3))
|
||||
wert = treffer.group(4)
|
||||
streams = titel.setdefault(t_nr, {})
|
||||
eintrag = streams.setdefault(s_nr, {"nr": s_nr, "typ": "", "lang": "",
|
||||
"sprache": "", "codec": "",
|
||||
"kanaele": "", "beschreibung": ""})
|
||||
if attr == SINFO_TYP:
|
||||
eintrag["typ"] = wert
|
||||
elif attr == SINFO_SPRACHE_CODE:
|
||||
eintrag["lang"] = wert
|
||||
elif attr == SINFO_SPRACHE_NAME:
|
||||
eintrag["sprache"] = wert
|
||||
elif attr == SINFO_CODEC:
|
||||
eintrag["codec"] = wert
|
||||
elif attr == SINFO_KANAELE:
|
||||
eintrag["kanaele"] = wert
|
||||
elif attr == SINFO_BESCHREIBUNG:
|
||||
eintrag["beschreibung"] = wert
|
||||
|
||||
ergebnis = {}
|
||||
for t_nr, streams in titel.items():
|
||||
audio, untertitel = [], []
|
||||
for s_nr in sorted(streams):
|
||||
s = streams[s_nr]
|
||||
# MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
|
||||
if s["typ"] == "Audio":
|
||||
audio.append(s)
|
||||
elif s["typ"].startswith("Subtitle"):
|
||||
untertitel.append(s)
|
||||
ergebnis[t_nr] = {"audio": audio, "untertitel": untertitel}
|
||||
return ergebnis
|
||||
|
||||
|
||||
def sprachen_zusammenfassen(streams_je_titel: dict) -> dict:
|
||||
"""Welche Sprachen hat die DISC insgesamt? (pure Funktion)
|
||||
|
||||
Für die Frage vor dem Rip zählt nicht Titel 3 Stream 7, sondern: „Diese Disc
|
||||
hat Ton auf Deutsch, Englisch und Japanisch, Untertitel auf Deutsch und
|
||||
Englisch." Deshalb über alle Titel vereinigt, Reihenfolge stabil (so wie sie
|
||||
auf der Disc stehen).
|
||||
|
||||
Rückgabe: {"audio": [{"lang","sprache","spuren"}], "untertitel": [...]}
|
||||
"""
|
||||
def sammeln(schluessel):
|
||||
gefunden = {}
|
||||
for streams in (streams_je_titel or {}).values():
|
||||
for s in streams.get(schluessel) or []:
|
||||
code = s.get("lang") or ""
|
||||
if not code:
|
||||
continue
|
||||
eintrag = gefunden.setdefault(
|
||||
code, {"lang": code, "sprache": s.get("sprache") or code,
|
||||
"spuren": 0})
|
||||
eintrag["spuren"] += 1
|
||||
return list(gefunden.values())
|
||||
|
||||
return {"audio": sammeln("audio"), "untertitel": sammeln("untertitel")}
|
||||
|
||||
|
||||
def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
||||
"""(Titel-Liste, Streams je Titel) der eingelegten Disc — EIN Info-Lauf.
|
||||
|
||||
Beides aus demselben Aufruf: Ein `makemkvcon info` dauert je Disc 20–120 s,
|
||||
und die Sprach-Auskunft steht in derselben Ausgabe. Sie zweimal zu holen
|
||||
wäre eine Minute Wartezeit für nichts.
|
||||
"""
|
||||
ergebnis = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return parse_titel_info(ergebnis.stdout or "")
|
||||
ausgabe = text_von(ergebnis.stdout)
|
||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||
|
||||
|
||||
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
@@ -149,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||
gesamt = len(titel_liste)
|
||||
alle_dateien = []
|
||||
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||
lesefehler = False
|
||||
for index, nr in enumerate(titel_liste):
|
||||
def anteilig(p, _i=index):
|
||||
if progress_cb:
|
||||
@@ -156,32 +302,68 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
|
||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||
log_cb=log_cb)
|
||||
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||
if ergebnis.get("status") == "cancelled":
|
||||
return ergebnis
|
||||
if ergebnis.get("status") != "success":
|
||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||
ergebnis["lesefehler"] = lesefehler
|
||||
return ergebnis
|
||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||
"lesefehler": lesefehler}
|
||||
|
||||
|
||||
def laengster_titel(dauern: dict):
|
||||
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||
def laengster_titel(dauern: dict, meta: dict = None):
|
||||
"""Titel-Nummer für den Hauptfilm (pure Funktion).
|
||||
|
||||
Mit meta['runtime'] wird heuristisch der Titel gesucht, dessen Laufzeit am nächsten
|
||||
an der TMDB-Laufzeit liegt (vermeidet 'Play All'-Tracks, die länger als der Film sind).
|
||||
Ohne runtime wird klassisch der längste Titel gewählt.
|
||||
"""
|
||||
if not dauern:
|
||||
return None
|
||||
|
||||
ziel_s = (meta.get("runtime") or 0) * 60 if meta else 0
|
||||
if ziel_s > 0:
|
||||
bester = min(dauern, key=lambda t: abs(dauern[t] - ziel_s))
|
||||
if abs(dauern[bester] - ziel_s) <= 20 * 60:
|
||||
return bester
|
||||
|
||||
return max(dauern, key=dauern.get)
|
||||
|
||||
|
||||
def episoden_titel(dauern: dict, meta: dict) -> list:
|
||||
"""Findet Episoden-Titel anhand der TMDB-Episoden-Laufzeiten.
|
||||
|
||||
Toleranz: +/- 10 Minuten (600 Sekunden) zum Durchschnitt.
|
||||
Gibt eine sortierte Liste der gefundenen Titelnummern zurück.
|
||||
"""
|
||||
if not dauern or not meta or not meta.get("episode_runtimes"):
|
||||
return []
|
||||
|
||||
runtimes_s = [r * 60 for r in meta["episode_runtimes"]]
|
||||
avg_runtime = sum(runtimes_s) / len(runtimes_s)
|
||||
|
||||
gefundene_titel = []
|
||||
for titel, dauer in dauern.items():
|
||||
if abs(dauer - avg_runtime) <= 10 * 60:
|
||||
gefundene_titel.append(int(titel))
|
||||
|
||||
return sorted(gefundene_titel)
|
||||
|
||||
|
||||
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {}
|
||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
||||
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||
|
||||
|
||||
def parse_scan_dauer(ausgabe: str) -> int:
|
||||
@@ -204,8 +386,9 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||
return 0
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 0
|
||||
@@ -250,7 +433,7 @@ def get_progress_from_prgv(line: str) -> int:
|
||||
|
||||
def check_handbrake_installed() -> bool:
|
||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||
return shutil.which("HandBrakeCLI") is not None
|
||||
return bool(werkzeuge.finden("handbrake"))
|
||||
|
||||
|
||||
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||
@@ -318,22 +501,91 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||
|
||||
|
||||
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
|
||||
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||
".mov": "av_mov", ".webm": "av_webm"}
|
||||
|
||||
|
||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||
preset: str = DEFAULT_HB_PRESET,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||
|
||||
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
||||
|
||||
## Die Sprachauswahl greift HIER, nicht beim Rippen
|
||||
|
||||
Commander-Anforderung 26.07.2026. Bewusst in der KOMPRESSION und nicht im
|
||||
Rip, aus drei Gründen:
|
||||
|
||||
1. Der Rip bleibt vollständig und verlustfrei — das ist ein Muss-Feature
|
||||
(KONZEPT). Wer später eine andere Sprachwahl will, komprimiert neu, statt
|
||||
die Disc wieder einzulegen.
|
||||
2. MakeMKV kann Tracks nur über eine Auswahl-Profildatei filtern; HandBrake
|
||||
hat dafür dokumentierte Schalter, die genau die Codes nehmen, die MakeMKV
|
||||
liefert (ISO 639-2, „deu"/„eng" — beides am 26.07.2026 gegengeprüft).
|
||||
3. Wird ein Disc-Typ NICHT komprimiert (4K verlustfrei), bleiben ohnehin alle
|
||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||
|
||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||
"""
|
||||
return [
|
||||
"HandBrakeCLI",
|
||||
befehl = [
|
||||
werkzeug("handbrake"),
|
||||
"--input", input_path,
|
||||
"--output", output_path,
|
||||
"--preset", preset,
|
||||
"--all-audio",
|
||||
"--all-subtitles",
|
||||
]
|
||||
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||
#
|
||||
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||
# endete mit Code 0"
|
||||
#
|
||||
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||
# nichts und nannte das „fehlgeschlagen".
|
||||
#
|
||||
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||
# die auseinanderlaufen koennen.
|
||||
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||
if format_name:
|
||||
befehl += ["--format", format_name]
|
||||
audio = [s for s in (audio_sprachen or []) if s]
|
||||
if audio:
|
||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||
befehl.append("--first-audio")
|
||||
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||
#
|
||||
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||
#
|
||||
# unknown option (--audio-codec)
|
||||
# HandBrake has exited. $? = 0
|
||||
#
|
||||
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||
# festschreibt, statt ihn zu finden.
|
||||
#
|
||||
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||
if untertitel:
|
||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||
befehl.append("--all-subtitles")
|
||||
return befehl
|
||||
|
||||
|
||||
HB_ENCODE_RE = re.compile(
|
||||
@@ -374,8 +626,45 @@ def get_progress_from_line(line: str) -> int:
|
||||
return int(((aktuell - 1) * 100 + prozent) / gesamt)
|
||||
|
||||
|
||||
def sprachliste(wert) -> list:
|
||||
"""„deu, eng , ,DEU" → ["deu", "eng"] (pure Funktion).
|
||||
|
||||
Nimmt Komma-Text aus den Einstellungen oder eine fertige Liste. Doppelte und
|
||||
Leeres fliegen raus, Kleinschreibung wird erzwungen — HandBrake will
|
||||
ISO-639-2 in Kleinbuchstaben, und ein „DEU" aus einem Eingabefeld soll nicht
|
||||
still die ganze Auswahl unwirksam machen.
|
||||
"""
|
||||
if not wert:
|
||||
return []
|
||||
teile = wert.split(",") if isinstance(wert, str) else list(wert)
|
||||
sauber = []
|
||||
for t in teile:
|
||||
code = str(t).strip().lower()
|
||||
if code and code not in sauber:
|
||||
sauber.append(code)
|
||||
return sauber
|
||||
|
||||
|
||||
def _datei_daneben(erwartet: str) -> str:
|
||||
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||
|
||||
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||
HandBrake ueberhaupt schreiben kann.
|
||||
"""
|
||||
ordner = os.path.dirname(erwartet)
|
||||
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||
for endung in FORMATE:
|
||||
kandidat = os.path.join(ordner, stamm + endung)
|
||||
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||
return kandidat
|
||||
return ""
|
||||
|
||||
|
||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||
progress_cb=None, abbruch_cb=None) -> dict:
|
||||
progress_cb=None, abbruch_cb=None,
|
||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
|
||||
|
||||
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
|
||||
@@ -392,20 +681,100 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
build_handbrake_cmd(input_path, output_path, preset),
|
||||
build_handbrake_cmd(input_path, output_path, preset,
|
||||
audio_sprachen, untertitel_sprachen),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
**HB_LESEN,
|
||||
)
|
||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def unbekanntes_preset(zeile: str) -> str:
|
||||
"""Meldet HandBrake diese Zeile ein UNBEKANNTES Preset? Name oder "".
|
||||
|
||||
Wortlaut aus dem echten Lauf im Worker-Image (26.07.2026, AGENTS Regel D):
|
||||
|
||||
$ HandBrakeCLI --preset 'Gibt Es Nicht 999p' …
|
||||
Invalid preset Gibt Es Nicht 999p
|
||||
Valid presets are:
|
||||
|
||||
Ohne diese Übersetzung stand im UI nur „HandBrake endete mit Code 3" —
|
||||
und die eigentliche Ursache (ein Preset-Name, den DIESES HandBrake nicht
|
||||
kennt) war daraus nicht zu erraten. Genau dieser Fall wird häufiger, weil
|
||||
die Namen je HandBrake-Version verschieden sind und ein Remote-Worker eine
|
||||
andere Version haben kann als die Rippy-VM.
|
||||
"""
|
||||
text = (zeile or "").strip()
|
||||
kopf = "Invalid preset "
|
||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||
|
||||
|
||||
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||
HB_ZEILEN_PUFFER = 12
|
||||
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||
HB_ZEILEN_MELDUNG = 4
|
||||
|
||||
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||
|
||||
|
||||
def unbekannter_schalter(zeile: str) -> str:
|
||||
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||
|
||||
## Der Befund des Commanders (30.08.2026)
|
||||
|
||||
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||
|
||||
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||
Befehlszeile, die Rippy baute:
|
||||
|
||||
unknown option (--audio-codec)
|
||||
HandBrake has exited.
|
||||
$? = 0
|
||||
|
||||
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||
|
||||
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||
statt wieder einen ganzen Rip zu kosten.
|
||||
"""
|
||||
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||
return treffer.group(1).strip() if treffer else ""
|
||||
|
||||
|
||||
def hb_schluss(zeilen) -> str:
|
||||
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||
|
||||
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||
Ausgabe, nur hoerte niemand zu.
|
||||
"""
|
||||
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||
if not sauber:
|
||||
return ""
|
||||
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||
|
||||
|
||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||
falsches_preset = ""
|
||||
falscher_schalter = ""
|
||||
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||
letzte_zeilen = []
|
||||
try:
|
||||
for line in process.stdout:
|
||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||
@@ -413,6 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||
if abbruch_cb:
|
||||
abbruch_cb()
|
||||
if line.strip():
|
||||
letzte_zeilen.append(line)
|
||||
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||
del letzte_zeilen[0]
|
||||
if not falsches_preset:
|
||||
falsches_preset = unbekanntes_preset(line)
|
||||
if not falscher_schalter:
|
||||
falscher_schalter = unbekannter_schalter(line)
|
||||
progress = get_progress_from_line(line)
|
||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||
@@ -428,9 +805,73 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
||||
|
||||
if process.returncode == 0 and os.path.exists(output_path):
|
||||
return {"status": "success", "output_path": output_path}
|
||||
|
||||
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||
if falscher_schalter:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||
+ hb_schluss(letzte_zeilen)
|
||||
),
|
||||
"return_code": process.returncode,
|
||||
}
|
||||
|
||||
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||
#
|
||||
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||
#
|
||||
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||
if process.returncode == 0:
|
||||
daneben = _datei_daneben(output_path)
|
||||
if daneben:
|
||||
return {"status": "success", "output_path": daneben,
|
||||
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||
"(der Container kommt aus dem Preset)."
|
||||
% (os.path.basename(daneben),
|
||||
os.path.basename(output_path))}
|
||||
# Frueher stand hier geraten „Meist ist der Zielordner nicht
|
||||
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
|
||||
# und leer, der Grund ein falscher Schalter) — und die Vermutung
|
||||
# schickte die Suche in die falsche Richtung. Jetzt wird der
|
||||
# Ordner GENANNT und HandBrake selbst zitiert.
|
||||
return {
|
||||
"status": "error",
|
||||
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||
"entstanden (Zielordner: %s)."
|
||||
% os.path.dirname(output_path)
|
||||
+ hb_schluss(letzte_zeilen)),
|
||||
"return_code": 0,
|
||||
}
|
||||
if falsches_preset:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f'Das HandBrake dieses Workers kennt das Preset „{falsches_preset}" '
|
||||
'nicht. Preset-Namen unterscheiden sich zwischen '
|
||||
'HandBrake-Versionen. Abhilfe: Einstellungen → Verarbeitung, dort '
|
||||
'auf „Bestes wählen" klicken — die Liste dort kommt vom Worker '
|
||||
'selbst und enthält nur Namen, die es wirklich gibt.'
|
||||
),
|
||||
"return_code": process.returncode,
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
||||
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||
+ hb_schluss(letzte_zeilen)),
|
||||
"return_code": process.returncode,
|
||||
}
|
||||
|
||||
@@ -468,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
||||
return tmp.name
|
||||
|
||||
|
||||
def disc_fehlt(device_path: str, zustand=None) -> str:
|
||||
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
|
||||
|
||||
## Warum vorher nachgesehen wird (Befund 29.08.2026)
|
||||
|
||||
Der Commander bekam:
|
||||
|
||||
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||
|
||||
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
|
||||
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
|
||||
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
|
||||
und meldet einen Satz, aus dem niemand das schliessen kann.
|
||||
|
||||
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
|
||||
|
||||
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
|
||||
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
|
||||
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||
"""
|
||||
if zustand is None:
|
||||
try:
|
||||
from rippy import drives as _schicht
|
||||
|
||||
zustand = _schicht.treiber().drive_status
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
try:
|
||||
stand = zustand(device_path)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if stand == CDS_NO_DISC:
|
||||
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
|
||||
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
|
||||
"wieder hinein." % device_path)
|
||||
if stand == CDS_TRAY_OPEN:
|
||||
return "Die Schublade von %s ist offen." % device_path
|
||||
return ""
|
||||
|
||||
|
||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||
log_cb=None) -> dict:
|
||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||
@@ -482,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
if not check_makemkv_installed():
|
||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||
|
||||
fehlt = disc_fehlt(device_path)
|
||||
if fehlt:
|
||||
return {"status": "error", "error": fehlt}
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
@@ -489,16 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
build_makemkv_cmd(device_path, output_dir, titel),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
|
||||
# schrieb aber UTF-8 — daraus wurde beim Commander
|
||||
# „Das Öffnen der Disk schlug fehl".
|
||||
#
|
||||
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
|
||||
# nicht (Python warnt und puffert doch). Ungepuffert kommt
|
||||
# jede Fortschrittszeile sofort an — bei einem Rip, der
|
||||
# Stunden laeuft, ist das der Unterschied zwischen einem
|
||||
# Balken und einem eingefrorenen Fenster.
|
||||
bufsize=0,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
|
||||
letzte_meldung = ""
|
||||
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||
lesefehler = False
|
||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||
# (Befund 24.07., Summer-Wars-UHD).
|
||||
kritische_meldungen = []
|
||||
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
|
||||
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
|
||||
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
|
||||
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
|
||||
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
|
||||
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
|
||||
KRITISCH = (
|
||||
"volume key is unknown",
|
||||
"evaluation period has expired",
|
||||
@@ -506,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
"Too old version",
|
||||
)
|
||||
try:
|
||||
for line in process.stdout:
|
||||
for rohzeile in process.stdout:
|
||||
line = text_von(rohzeile)
|
||||
progress = get_progress_from_prgv(line)
|
||||
if progress >= 0:
|
||||
if progress_cb:
|
||||
@@ -516,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
if meldung is None:
|
||||
continue
|
||||
code, letzte_meldung = meldung
|
||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
||||
if LESEFEHLER_MARKE in letzte_meldung:
|
||||
lesefehler = True
|
||||
if code in KRITISCHE_CODES:
|
||||
kritische_meldungen.append(
|
||||
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||
elif any(muster in letzte_meldung for muster in KRITISCH):
|
||||
kritische_meldungen.append(letzte_meldung)
|
||||
if log_cb:
|
||||
try:
|
||||
@@ -544,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
"output_dir": output_dir,
|
||||
"files": mkv_dateien,
|
||||
"return_code": process.returncode,
|
||||
"lesefehler": lesefehler,
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"lesefehler": lesefehler,
|
||||
"error": (
|
||||
f"makemkvcon endete mit Code {process.returncode}"
|
||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||
@@ -561,15 +1079,14 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
|
||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||
output_dir: str = None, nur_hauptfilm: bool = False,
|
||||
titel_liste: list = None, log_cb=None) -> dict:
|
||||
titel_liste: list = None, log_cb=None, meta: dict = None) -> dict:
|
||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||
|
||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||
nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen.
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||
@@ -578,10 +1095,18 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
|
||||
|
||||
# Serien-Erkennung: Wenn keine Titel explizit gewählt wurden und es eine Serie ist
|
||||
if meta and meta.get("series") and meta.get("episode_runtimes"):
|
||||
dauern = lies_titel_dauern(device_path)
|
||||
episoden = episoden_titel(dauern, meta)
|
||||
if episoden:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return rip_titel_auswahl(device_path, output_dir, episoden, progress_cb, log_cb)
|
||||
|
||||
titel = "all"
|
||||
if nur_hauptfilm:
|
||||
dauern = lies_titel_dauern(device_path)
|
||||
haupt = laengster_titel(dauern)
|
||||
haupt = laengster_titel(dauern, meta)
|
||||
if haupt is not None:
|
||||
titel = str(haupt)
|
||||
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
||||
@@ -589,10 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
log_cb=log_cb)
|
||||
|
||||
|
||||
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||
|
||||
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||
"""
|
||||
from rippy.drives import windows as win
|
||||
from rippy.rip import audio_cd
|
||||
|
||||
class Leser:
|
||||
toc = staticmethod(win.audio_toc)
|
||||
|
||||
@staticmethod
|
||||
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||
fortschritt)
|
||||
|
||||
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||
# Disc, die niemand kennt.
|
||||
from rippy.rip import musicbrainz_cd
|
||||
|
||||
try:
|
||||
spuren = win.audio_toc(device_path)
|
||||
antwort = musicbrainz_cd.abfragen(
|
||||
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||
except Exception: # noqa: BLE001
|
||||
antwort = None
|
||||
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||
|
||||
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||
werkzeuge.finden("flac"),
|
||||
fortschritt=progress_cb, tags_je_spur=tags)
|
||||
if auswerfen and ergebnis.get("status") == "success":
|
||||
wirf_disc_aus(device_path)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||
auswerfen: bool = True) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||
Auswurf" (abcde macht das selbst per -x)."""
|
||||
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||
#
|
||||
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||
#
|
||||
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||
# Wochen so im SAVEPOINT.
|
||||
if os.name == "nt":
|
||||
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||
auswerfen)
|
||||
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -620,7 +1201,8 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Schlüssel-Automatik für 4K-UHD — der Windows-PC holt, was Linux nie holt.
|
||||
|
||||
## Das Problem, das das hier löst
|
||||
|
||||
Belegt am 25.07.2026 auf BEIDEN Maschinen: `makemkvcon` unter **Linux** ruft
|
||||
Disc-Schlüssel NIE ab — kein einziger Verbindungsversuch, geprüft mit leerem und
|
||||
gefülltem Speicher, mit und ohne `--noscan`. Die **Windows**-Version tut es
|
||||
(Meldung 3338). Deshalb scheitert jede unbekannte UHD-Disc auf der Rippy-VM mit
|
||||
„The volume key is unknown", und der Weg, der funktioniert, war bisher
|
||||
Handarbeit: Laufwerk an den Windows-PC, Disc öffnen, `_private_data.tar` suchen,
|
||||
im UI hochladen.
|
||||
|
||||
Das macht diese Datei automatisch.
|
||||
|
||||
## Zwei Schichten, absichtlich getrennt
|
||||
|
||||
**1. Der Wächter (verlässlich).** Er sieht `_private_data.tar` nach und lädt sie
|
||||
zu Rippy hoch, sobald sie sich geändert hat. Er braucht keine
|
||||
Laufwerkserkennung, kein Disc-Öffnen, nichts geraten — wenn MakeMKV neue
|
||||
Schlüssel gelernt hat, wandern sie rüber. Das deckt auch den Fall ab, dass der
|
||||
Nutzer die Disc einfach in der MakeMKV-Oberfläche öffnet.
|
||||
|
||||
**2. Das Anstoßen (nach bestem Wissen).** Liegt eine Disc im Laufwerk dieses PCs,
|
||||
wird `makemkvcon info` darauf losgelassen — dabei holt MakeMKV den Schlüssel.
|
||||
|
||||
Warum die Trennung: Das Format der belegten `DRV:`-Zeile konnte auf dem
|
||||
Commander-PC nicht gemessen werden (dort steckt kein optisches Laufwerk, alle 16
|
||||
Plätze melden `DRV:i,256,999,0,"","",""` — gemessen). Geraten wird deshalb nur in
|
||||
Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach nichts —
|
||||
Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im verlässlichen Teil.
|
||||
|
||||
## Gemessene Fundstellen (AGENTS Regel D)
|
||||
|
||||
- Datenverzeichnis unter Windows: `%USERPROFILE%\\.MakeMKV` — dort lag die echte
|
||||
`_private_data.tar` (6.420.480 Bytes). NICHT `%APPDATA%\\MakeMKV`, wie man
|
||||
vermuten würde.
|
||||
- Programm: `C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe` (v1.18.4).
|
||||
- Laufwerksliste: `makemkvcon -r --cache=1 info disc:9999` gibt `DRV:`-Zeilen.
|
||||
- Hochladen: `POST /api/system/keystore` mit ROHEM Körper (kein JSON, kein
|
||||
Multipart — die API hat kein python-multipart).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
|
||||
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
||||
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
||||
|
||||
# Wie oft nachgesehen wird. Eine Minute reicht: Discs wechseln nicht im
|
||||
# Sekundentakt, und ein `makemkvcon info` belastet das Laufwerk.
|
||||
TAKT_SEKUNDEN = 60
|
||||
|
||||
|
||||
def daten_verzeichnis() -> str:
|
||||
"""MakeMKVs Datenverzeichnis auf DIESEM Rechner (leer, wenn nicht gefunden).
|
||||
|
||||
`%USERPROFILE%\\.MakeMKV` ist der gemessene Ort (26.07.2026 auf dem
|
||||
Commander-PC, MakeMKV 1.18.4). Die beiden anderen Kandidaten stehen als
|
||||
Rückfall drin, weil MakeMKV über die Versionen umgezogen ist — geprüft wird,
|
||||
welcher wirklich existiert, statt einen zu behaupten.
|
||||
"""
|
||||
profil = os.getenv("USERPROFILE") or os.path.expanduser("~")
|
||||
kandidaten = [
|
||||
os.path.join(profil, ".MakeMKV"),
|
||||
os.path.join(os.getenv("APPDATA") or "", "MakeMKV"),
|
||||
os.path.join(os.getenv("LOCALAPPDATA") or "", "MakeMKV"),
|
||||
]
|
||||
for pfad in kandidaten:
|
||||
if pfad and os.path.isdir(pfad):
|
||||
return pfad
|
||||
return ""
|
||||
|
||||
|
||||
def schluesseldatei() -> str:
|
||||
"""Voller Pfad zu `_private_data.tar` (leer, wenn es sie nicht gibt)."""
|
||||
ordner = daten_verzeichnis()
|
||||
if not ordner:
|
||||
return ""
|
||||
datei = os.path.join(ordner, "_private_data.tar")
|
||||
return datei if os.path.isfile(datei) else ""
|
||||
|
||||
|
||||
def makemkvcon_pfad() -> str:
|
||||
"""makemkvcon auf dieser Maschine (leer, wenn MakeMKV nicht installiert ist).
|
||||
|
||||
Die 64-Bit-Variante zuerst — auf dem Commander-PC liegen beide, und die
|
||||
32-Bit-Version ist nur noch Beiwerk.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
fest = [
|
||||
r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe",
|
||||
r"C:\Program Files (x86)\MakeMKV\makemkvcon.exe",
|
||||
r"C:\Program Files\MakeMKV\makemkvcon64.exe",
|
||||
r"C:\Program Files\MakeMKV\makemkvcon.exe",
|
||||
]
|
||||
for pfad in fest:
|
||||
if os.path.isfile(pfad):
|
||||
return pfad
|
||||
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||
from rippy.tools import katalog
|
||||
|
||||
return (katalog.finden("makemkv")
|
||||
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||
|
||||
|
||||
def parse_laufwerke(ausgabe: str) -> list:
|
||||
"""`DRV:`-Zeilen → Laufwerke mit Disc (pure Funktion).
|
||||
|
||||
Rückgabe: [{"index", "laufwerk", "disc", "geraet"}] — NUR Einträge, bei denen
|
||||
ein Disc-Name steht. Ein leerer Platz sieht so aus (gemessen):
|
||||
|
||||
DRV:0,256,999,0,"","",""
|
||||
|
||||
⚠️ Die BELEGTE Form ist nicht gemessen — auf dem Commander-PC steckt kein
|
||||
optisches Laufwerk. Deshalb ist die Regel bewusst konservativ: ohne
|
||||
Disc-Namen gilt „nichts da", und dann tut die Automatik einfach nichts.
|
||||
Falsch-negativ ist hier harmlos (der Wächter greift trotzdem),
|
||||
falsch-positiv wäre ein `makemkvcon`-Lauf ins Leere.
|
||||
"""
|
||||
gefunden = []
|
||||
for zeile in (ausgabe or "").splitlines():
|
||||
treffer = DRV_ZEILE.match(zeile.strip())
|
||||
if not treffer:
|
||||
continue
|
||||
index, _zustand, _flags, _x, laufwerk, disc, geraet = treffer.groups()
|
||||
if not disc.strip():
|
||||
continue
|
||||
gefunden.append({
|
||||
"index": int(index),
|
||||
"laufwerk": laufwerk,
|
||||
"disc": disc,
|
||||
"geraet": geraet,
|
||||
})
|
||||
return gefunden
|
||||
|
||||
|
||||
def datei_stand(pfad: str) -> tuple:
|
||||
"""(Größe, Änderungszeit) — die Kennung, an der eine Änderung auffällt."""
|
||||
try:
|
||||
s = os.stat(pfad)
|
||||
return (s.st_size, int(s.st_mtime))
|
||||
except OSError:
|
||||
return (0, 0)
|
||||
|
||||
|
||||
def hat_sich_geaendert(vorher: tuple, jetzt: tuple) -> bool:
|
||||
"""Lohnt ein Upload? (pure Funktion)
|
||||
|
||||
Nur wenn die Datei EXISTIERT und sich unterscheidet. Beim allerersten Lauf
|
||||
(vorher = None) wird ebenfalls hochgeladen: Rippy soll den Bestand kennen,
|
||||
auch wenn MakeMKV gerade nichts Neues gelernt hat.
|
||||
"""
|
||||
if jetzt == (0, 0):
|
||||
return False
|
||||
return vorher is None or vorher != jetzt
|
||||
|
||||
|
||||
def laufwerke_lesen(programm: str, laufen=None, timeout: int = 120) -> list:
|
||||
"""Welche Laufwerke dieses PCs haben eine Disc? (leer bei jedem Fehler)"""
|
||||
if not programm:
|
||||
return []
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
ergebnis = starten(
|
||||
[programm, "-r", "--cache=1", "info", "disc:9999"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
errors="replace", creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return parse_laufwerke(ergebnis.stdout or "")
|
||||
|
||||
|
||||
def disc_oeffnen(programm: str, index: int, laufen=None, timeout: int = 600) -> bool:
|
||||
"""Lässt MakeMKV die Disc lesen — dabei holt es unter Windows den Schlüssel.
|
||||
|
||||
Der Rückgabewert sagt nur, ob der Aufruf durchlief. Ob ein Schlüssel dabei
|
||||
herauskam, entscheidet allein die Datei — deshalb wird danach ihr Stand
|
||||
verglichen und nicht diese Antwort geglaubt.
|
||||
"""
|
||||
if not programm:
|
||||
return False
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
starten(
|
||||
[programm, "-r", "--noscan", "info", f"disc:{index}"],
|
||||
capture_output=True, text=True, timeout=timeout, errors="replace",
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def hochladen(host: str, datei: str, oeffner=None, timeout: int = 120) -> str:
|
||||
"""`_private_data.tar` zu Rippy schicken. "" = geklappt, sonst der Fehler.
|
||||
|
||||
ROHER Körper, kein JSON und kein Multipart — genau so nimmt
|
||||
`POST /system/keystore` die Datei an (der API fehlt python-multipart).
|
||||
"""
|
||||
if not host:
|
||||
return "keine Rippy-Adresse gesetzt"
|
||||
if not datei:
|
||||
return "keine Schlüsseldatei gefunden"
|
||||
try:
|
||||
with open(datei, "rb") as f:
|
||||
inhalt = f.read()
|
||||
except OSError as e:
|
||||
return f"Schlüsseldatei nicht lesbar: {e}"
|
||||
|
||||
anfrage = urllib.request.Request(
|
||||
f"http://{host}/api/system/keystore", data=inhalt, method="POST",
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
)
|
||||
macher = oeffner or urllib.request.urlopen
|
||||
try:
|
||||
with macher(anfrage, timeout=timeout) as antwort:
|
||||
antwort.read()
|
||||
return ""
|
||||
except Exception as e:
|
||||
return f"Hochladen fehlgeschlagen: {e}"
|
||||
|
||||
|
||||
def runde(host: str, letzter_stand, melden=None, programm=None,
|
||||
laufen=None, oeffner=None) -> tuple:
|
||||
"""Ein Durchlauf der Automatik. Rückgabe: (neuer_stand, was_passiert_ist).
|
||||
|
||||
Reihenfolge mit Absicht: ERST anstoßen (falls eine Disc liegt), DANN die
|
||||
Datei vergleichen. So wird ein gerade geholter Schlüssel in derselben Runde
|
||||
mitgenommen, statt eine Minute zu warten.
|
||||
"""
|
||||
sage = melden or (lambda level, text: None)
|
||||
prog = programm if programm is not None else makemkvcon_pfad()
|
||||
if not prog:
|
||||
return letzter_stand, "kein-makemkv"
|
||||
|
||||
for laufwerk in laufwerke_lesen(prog, laufen=laufen):
|
||||
sage("info", f"Disc erkannt: {laufwerk['disc']} — MakeMKV liest sie, "
|
||||
"um den Disc-Schlüssel zu holen")
|
||||
disc_oeffnen(prog, laufwerk["index"], laufen=laufen)
|
||||
|
||||
datei = schluesseldatei()
|
||||
jetzt = datei_stand(datei) if datei else (0, 0)
|
||||
if not hat_sich_geaendert(letzter_stand, jetzt):
|
||||
return letzter_stand, "unveraendert"
|
||||
|
||||
fehler = hochladen(host, datei, oeffner=oeffner)
|
||||
if fehler:
|
||||
sage("warning", f"Schlüsselspeicher konnte nicht zu Rippy: {fehler}")
|
||||
return letzter_stand, "fehler"
|
||||
sage("success",
|
||||
f"Schlüsselspeicher an Rippy übergeben ({jetzt[0]} Bytes) — "
|
||||
"wirkt ab dem nächsten Rip")
|
||||
return jetzt, "hochgeladen"
|
||||
+60
-849
@@ -1,56 +1,46 @@
|
||||
"""Rip- und Transcode-Tasks — bewusst GETRENNT (23.07.2026, Basis für Etappe 12/20).
|
||||
"""Celery-Adapter: dieselben Task-Namen wie bisher, der Ablauf liegt in ablauf.py.
|
||||
|
||||
Warum zwei Tasks: Rippen braucht das Laufwerk (läuft immer lokal), Kompression
|
||||
braucht nur CPU/GPU + Zugriff auf die Rohdatei. Als eigener Celery-Task auf der
|
||||
Queue "transcode" kann die Kompression damit auch ein Remote-Worker mit GPU
|
||||
übernehmen (optionales Add-on) — und fehlgeschlagene Kompressionen lassen sich
|
||||
neu anstoßen, ohne die Disc neu zu rippen (POST /jobs/{id}/retry-transcode).
|
||||
## Was sich geaendert hat (V2-4, 28.08.2026) — und was NICHT
|
||||
|
||||
Zwei Stufen (Commander-Entscheid 23.07.): MakeMKV rippt verlustfrei (einziger
|
||||
Weg durch AACS — HandBrake kann verschlüsselte Discs nicht lesen), HandBrake
|
||||
komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
||||
Nicht geaendert: die Task-Namen (`worker.tasks.rip_disc`,
|
||||
`worker.tasks.transcode_files`, `worker.tasks.scan_tracks`,
|
||||
`worker.tasks.ping_worker`), die Argumente, die Queues, das Verhalten. Ein
|
||||
laufender Docker-Betrieb merkt von diesem Umbau nichts — und ein bereits
|
||||
installierter Windows-Worker aus v1 auch nicht.
|
||||
|
||||
Geaendert: Der ABLAUF steht nicht mehr hier. Diese Datei importierte auf
|
||||
Modulebene `celery_app`, und damit war der gesamte Rip-Vorgang an einen
|
||||
Broker gebunden. Im nativen Windows-Betrieb — der bewusst keinen hat — war
|
||||
er ueberhaupt nicht ladbar.
|
||||
|
||||
Uebrig bleibt hier genau das, was WIRKLICH Celery ist: die Task-Huellen und
|
||||
die Wahl der Ziel-Queue.
|
||||
|
||||
## Die zwei Rueckrufe
|
||||
|
||||
`ablauf.rippen()` weiss nicht, wer zuhoert. Diese Datei reicht ihm die
|
||||
Celery-Fassung herein:
|
||||
|
||||
melde -> self.update_state(...) wie bisher
|
||||
weiterreichen -> transcode_files.apply_async(queue=...)
|
||||
|
||||
Der Standalone-Laeufer reicht stattdessen seine eigenen herein — oder gar
|
||||
keine, dann komprimiert `ablauf` gleich selbst.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
import db
|
||||
import makemkv_daten
|
||||
import medien
|
||||
import notify
|
||||
import ablauf
|
||||
from celery_app import celery_app
|
||||
|
||||
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
|
||||
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
|
||||
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
|
||||
try:
|
||||
from detection import detect_disc_type, disc_size_bytes
|
||||
except ImportError: # Windows: kein fcntl
|
||||
detect_disc_type = None
|
||||
disc_size_bytes = None
|
||||
|
||||
from ripping import (
|
||||
RIP_OUTPUT_DIR,
|
||||
RipAbbruch,
|
||||
komprimieren_fuer,
|
||||
lies_datei_dauer,
|
||||
lies_titel_info,
|
||||
preset_fuer,
|
||||
rip_cd,
|
||||
rip_video,
|
||||
run_handbrake,
|
||||
wirf_disc_aus,
|
||||
)
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
# Namen, die andere Module bisher aus tasks.py geholt haben. Sie liegen jetzt
|
||||
# in ablauf.py; hier stehen sie weiter zur Verfuegung, damit kein Aufrufer
|
||||
# angefasst werden muss.
|
||||
RIP_FERTIG = ablauf.RIP_FERTIG
|
||||
API_URL = ablauf.API_URL
|
||||
pfad_lokal = ablauf.pfad_lokal
|
||||
unter_wurzel = ablauf.unter_wurzel
|
||||
erster_vorhandener_ordner = ablauf.erster_vorhandener_ordner
|
||||
|
||||
|
||||
def _transcode_queue(node: str):
|
||||
@@ -69,822 +59,43 @@ def _transcode_queue(node: str):
|
||||
return "transcode"
|
||||
|
||||
|
||||
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
||||
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
||||
|
||||
Der native Windows-Worker sieht /app/temp und /app/media nicht — er
|
||||
mountet Rippys Freigabe als Netzlaufwerk und setzt RIPPY_PATH_MAP,
|
||||
z. B. "/app/media=Z:\\media;/app/temp=Y:\\temp". Ohne Mapping (Docker-
|
||||
Worker: identische Mounts) kommt der Pfad unverändert zurück.
|
||||
"""
|
||||
mapping = mapping if mapping is not None else os.getenv("RIPPY_PATH_MAP", "")
|
||||
if not pfad or not mapping:
|
||||
return pfad
|
||||
for paar in mapping.split(";"):
|
||||
if "=" not in paar:
|
||||
continue
|
||||
quelle, ziel = paar.split("=", 1)
|
||||
if quelle and pfad.startswith(quelle):
|
||||
rest = pfad[len(quelle):]
|
||||
if "\\" in ziel:
|
||||
rest = rest.replace("/", "\\")
|
||||
return ziel + rest
|
||||
return pfad
|
||||
|
||||
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
# Wie oft während einer Kompression nachgesehen wird, ob der Nutzer abgebrochen
|
||||
# hat. Eine DB-Abfrage alle paar Sekunden ist nichts gegen einen Encode, der
|
||||
# Stunden läuft — und „Abbrechen" fühlt sich damit sofort an.
|
||||
ABBRUCH_INTERVALL_SEKUNDEN = 5
|
||||
|
||||
|
||||
def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
||||
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
|
||||
|
||||
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
|
||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb
|
||||
(Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind
|
||||
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
|
||||
|
||||
Gleichlautend in docker/api/main.py; es gibt kein geteiltes Paket zwischen
|
||||
den Containern.
|
||||
"""
|
||||
if not pfad or not wurzel:
|
||||
return False
|
||||
sauber = wurzel.rstrip("/") or "/"
|
||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||
|
||||
|
||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||
|
||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||
das sind IMMER Container-Pfade. os.path.normpath macht unter Windows
|
||||
Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr, das
|
||||
gewählte Ziel fiele still auf den Standard zurück. Aufgefallen 25.07.2026,
|
||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||
"""
|
||||
if target_dir:
|
||||
normalisiert = posixpath.normpath(target_dir)
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||
`workDir` → Container-Default /app/temp/raw.
|
||||
|
||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||||
|
||||
Pro Rip wählbar seit 25.07.2026 (Commander-Wunsch): beim „Rippen starten"
|
||||
entscheidet man je Disc, wo die Rohdaten landen. Das Setting bleibt der
|
||||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||
denen niemand gefragt wird.
|
||||
"""
|
||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||
wert = (kandidat or "").strip()
|
||||
if wert:
|
||||
normalisiert = posixpath.normpath(wert)
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return RAW_DIR
|
||||
|
||||
|
||||
def _frei_bytes(pfad: str) -> int:
|
||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
||||
kandidat = pfad
|
||||
while kandidat and not os.path.exists(kandidat):
|
||||
kandidat = os.path.dirname(kandidat)
|
||||
try:
|
||||
return shutil.disk_usage(kandidat or "/").free
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
|
||||
def _platz_pruefen(pfad: str, benoetigt: int, zweck: str) -> str:
|
||||
"""Gibt eine Klartext-Fehlermeldung zurück, wenn der Platz nicht reicht —
|
||||
sonst leeren String. VOR dem Rip geprüft: eine volle Platte nach 40 GB
|
||||
ist der teuerste Fehlschlag, den es gibt."""
|
||||
frei = _frei_bytes(pfad)
|
||||
if frei < 0 or benoetigt <= 0:
|
||||
return "" # unbekannt → nicht raten, lieber rippen lassen
|
||||
if frei >= benoetigt:
|
||||
return ""
|
||||
return (
|
||||
f"Zu wenig Platz für {zweck} in {pfad}: "
|
||||
f"{frei / 1024**3:.1f} GB frei, benötigt ~{benoetigt / 1024**3:.1f} GB. "
|
||||
"Abhilfe: anderes Ziel wählen oder das Arbeitsverzeichnis unter "
|
||||
"Einstellungen → Verarbeitung auf eine große Freigabe legen."
|
||||
)
|
||||
|
||||
|
||||
def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||
"""MakeMKV-Beta-Key aus den UI-Einstellungen aktivieren (schlägt Env).
|
||||
|
||||
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||||
|
||||
Ergänzend statt überschreibend (Befund 25.07.2026): das Datenverzeichnis
|
||||
ist jetzt persistent, und hier stand vorher ein open(..., "w") — das warf
|
||||
vor JEDEM Rip alles andere aus der settings.conf, z. B. app_UpdateEnable
|
||||
aus dem entrypoint. Beide Schreiber müssen gleich arbeiten, sonst kommt
|
||||
der Fehler beim nächsten Rip still zurück.
|
||||
"""
|
||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||
if not key:
|
||||
return
|
||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
||||
try:
|
||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
||||
try:
|
||||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
||||
alt = f.read()
|
||||
except OSError:
|
||||
alt = ""
|
||||
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
||||
except OSError as e:
|
||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||
|
||||
|
||||
def _ordner_groesse(pfad: str) -> int:
|
||||
"""Belegter Platz eines Ordners in Bytes (0, wenn nicht lesbar)."""
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for name in dateien:
|
||||
try:
|
||||
summe += os.path.getsize(os.path.join(wurzel, name))
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
|
||||
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
||||
|
||||
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
||||
shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
|
||||
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
|
||||
zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
|
||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
|
||||
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
|
||||
fertig war. Der Nutzer sah nur „nichts da".
|
||||
|
||||
## Warum hier NICHT vorhergesagt, sondern versucht wird
|
||||
|
||||
Die erste Fassung dieses Schutzes verglich `os.stat(...).st_dev` und
|
||||
schloss aus gleichen Werten auf „reines Umhängen, kein Platz nötig". Am
|
||||
25.07.2026 im Worker-Container nachgemessen — beides zugleich wahr:
|
||||
|
||||
st_dev /app/temp = 2050
|
||||
st_dev /app/media = 2050 → also identisch
|
||||
os.rename(...) → EXDEV, „Invalid cross-device link"
|
||||
|
||||
Der Kernel vergleicht bei rename() den **Mount**, nicht das Gerät. /app/temp
|
||||
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
|
||||
ext4-Partition. Die st_dev-Prüfung war deshalb wirkungslos: sie sah
|
||||
„gleiches Dateisystem", übersprang die Platzprüfung, und shutil.move kopierte
|
||||
doch. Der Schutz hätte genau den Schaden zugelassen, gegen den er gebaut war.
|
||||
|
||||
Also: erst rename VERSUCHEN. Klappt es, ist es umgehängt und fertig.
|
||||
Kommt EXDEV, steht fest, dass kopiert werden müsste — und erst dann wird
|
||||
der Platz geprüft. Das ist keine Vermutung mehr, sondern die Antwort des
|
||||
Kernels.
|
||||
"""
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
try:
|
||||
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
|
||||
# verlässliche Test, ob überhaupt umgehängt werden kann.
|
||||
try:
|
||||
os.rename(raw_dir, ziel_original)
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
|
||||
"kein zusätzlicher Platz nötig)",
|
||||
)
|
||||
return
|
||||
except OSError as e:
|
||||
if e.errno != errno.EXDEV:
|
||||
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
|
||||
|
||||
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
|
||||
benoetigt = _ordner_groesse(raw_dir)
|
||||
frei = _frei_bytes(final_dir)
|
||||
if frei < benoetigt * 1.05:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original NICHT aufgehoben — Ziel liegt auf einem "
|
||||
f"anderen Mount, es müsste kopiert werden. Dafür wären "
|
||||
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
||||
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
||||
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
||||
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
||||
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
||||
)
|
||||
return
|
||||
shutil.move(raw_dir, ziel_original)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||
except OSError as e:
|
||||
# Halb geschriebene Kopie wegräumen, sonst belegt sie für immer Platz.
|
||||
shutil.rmtree(ziel_original, ignore_errors=True)
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original konnte nicht aufgehoben werden ({e}). "
|
||||
f"Die komprimierte Datei ist fertig; die Roh-Datei bleibt unter {raw_dir}."
|
||||
)
|
||||
|
||||
|
||||
def _abbruch_angefordert(job_id: str) -> bool:
|
||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||
return db.get_job_status(job_id) == "canceling"
|
||||
|
||||
|
||||
def _serien_episoden_zuordnen(ausgabe: str, serie: str, staffel, meta: dict) -> list:
|
||||
"""Episoden-Zuordnung per Laufzeitabgleich (ARM-Wunde #395, besser gelöst).
|
||||
|
||||
Laufzeiten der Staffel kommen von der API (TMDB); die der Dateien vom
|
||||
HandBrake-Scan. Nur bei EINDEUTIGEM Treffer wird umbenannt. Wirft nie.
|
||||
"""
|
||||
if meta.get("source") != "tmdb" or meta.get("type") != "tv" or not meta.get("id"):
|
||||
return ["Episoden-Zuordnung übersprungen (nur mit TMDB-Serien-Treffer möglich)"]
|
||||
try:
|
||||
antwort = requests.get(
|
||||
f"{API_URL}/metadata/tv/{meta['id']}/season/{int(staffel)}", timeout=30
|
||||
)
|
||||
if antwort.status_code != 200:
|
||||
return [f"Episoden-Laufzeiten nicht abrufbar (HTTP {antwort.status_code})"]
|
||||
episoden = [
|
||||
(e["episode"], (e.get("runtime") or 0) * 60)
|
||||
for e in antwort.json().get("episodes", [])
|
||||
]
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
return [f"Episoden-Laufzeiten nicht abrufbar: {e}"]
|
||||
if not episoden:
|
||||
return ["Keine Episoden-Laufzeiten bei TMDB hinterlegt — Dateinamen bleiben"]
|
||||
|
||||
dateien = sorted(
|
||||
os.path.join(ausgabe, f) for f in os.listdir(ausgabe) if f.endswith(".mkv")
|
||||
)
|
||||
if not dateien:
|
||||
return []
|
||||
dauern = [lies_datei_dauer(p) for p in dateien]
|
||||
if 0 in dauern:
|
||||
return ["Datei-Laufzeiten nicht lesbar — Dateinamen bleiben"]
|
||||
|
||||
zuordnung = medien.matche_episoden(dauern, episoden)
|
||||
if not zuordnung:
|
||||
return [
|
||||
"Keine EINDEUTIGE Episoden-Zuordnung über die Laufzeiten — "
|
||||
"Dateinamen bleiben (lieber ehrlich als falsch benannt)"
|
||||
]
|
||||
return medien.episoden_umbenennen(ausgabe, serie, staffel, zuordnung)
|
||||
|
||||
|
||||
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
||||
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
||||
einstellungen = db.get_settings()
|
||||
url = (einstellungen.get("notificationWebhook") or "").strip()
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
notify.sende(url, betreff, text, level)
|
||||
except Exception as e:
|
||||
db.add_log("warning", "notify", f"Job {job_id}: Benachrichtigung fehlgeschlagen: {e}")
|
||||
|
||||
|
||||
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres,
|
||||
bereitet bei Erfolg fürs Medien-System auf (NFO/Poster) und meldet das
|
||||
Ende per Webhook (falls konfiguriert)."""
|
||||
job = db.get_job(job_id) or {}
|
||||
titel = job.get("title") or job_id[:8]
|
||||
|
||||
if ergebnis.get("status") == "cancelled":
|
||||
db.update_job(
|
||||
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
||||
_benachrichtigen(job_id, f'⚠️ Rippy: „{titel}" abgebrochen',
|
||||
"Der Job wurde auf Wunsch abgebrochen — Rohdaten bleiben erhalten.",
|
||||
"warning")
|
||||
return
|
||||
if ergebnis.get("status") == "success":
|
||||
ausgabe = ergebnis.get("output_dir")
|
||||
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
|
||||
einstellungen = db.get_settings()
|
||||
try:
|
||||
meta = json.loads(job.get("meta") or "{}")
|
||||
except ValueError:
|
||||
meta = {}
|
||||
serie = meta.get("series")
|
||||
staffel = meta.get("season")
|
||||
|
||||
# Serien-Rips: Episoden per Laufzeit zuordnen und umbenennen
|
||||
# (Show S01E02.mkv) — nur bei EINDEUTIGER Zuordnung, sonst
|
||||
# bleiben die MakeMKV-Namen (ehrlich geloggt).
|
||||
if serie and staffel:
|
||||
for meldung in _serien_episoden_zuordnen(ausgabe, serie, staffel, meta):
|
||||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||
|
||||
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster);
|
||||
# bei Serien wandern tvshow.nfo/poster in den Serien-Ordner.
|
||||
for meldung in medien.aufbereiten(
|
||||
ausgabe, einstellungen.get("mediaServer") or "none",
|
||||
serie or job.get("title") or "", meta.get("year"), meta,
|
||||
serien_root=os.path.dirname(ausgabe) if serie and staffel else None,
|
||||
):
|
||||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||
|
||||
# Bibliotheks-Refresh (Jellyfin/Emby): der Server scannt sofort
|
||||
meldung = medien.bibliothek_refresh(
|
||||
einstellungen.get("mediaServer") or "none",
|
||||
(einstellungen.get("jellyfinUrl") or "").strip(),
|
||||
(einstellungen.get("jellyfinApiKey") or "").strip(),
|
||||
)
|
||||
if meldung:
|
||||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||
db.update_job(
|
||||
job_id,
|
||||
status="completed",
|
||||
progress=100,
|
||||
output_path=ausgabe,
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ausgabe}")
|
||||
_benachrichtigen(job_id, f'✅ Rippy: „{titel}" ist fertig',
|
||||
f"Abgelegt unter: {ausgabe}", "success")
|
||||
else:
|
||||
fehler = ergebnis.get("error", "unbekannter Fehler")
|
||||
db.update_job(
|
||||
job_id,
|
||||
status="failed",
|
||||
error=fehler,
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log("error", "worker", f"Job {job_id}: {fehler}")
|
||||
_benachrichtigen(job_id, f'❌ Rippy: „{titel}" fehlgeschlagen', fehler, "error")
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||
def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
"""Stufe 1: Rippt die Disc; bei Video folgt die Kompression als eigener Task.
|
||||
"""Rippt die Disc. Der Ablauf steht in ablauf.rippen()."""
|
||||
|
||||
target_dir (optional): vom Nutzer gewähltes Ablageziel unter /app/media —
|
||||
dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar.
|
||||
"""
|
||||
db.init_db()
|
||||
def melde(zustand: dict) -> None:
|
||||
self.update_state(state="PROGRESS", meta=zustand)
|
||||
|
||||
if detect_disc_type is None:
|
||||
ergebnis = {
|
||||
"status": "error",
|
||||
"error": "Dieser Worker kann nur komprimieren (nativer "
|
||||
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
|
||||
"wird auf der Rippy-Hauptmaschine.",
|
||||
}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
def weiterreichen(job: str, raw_dir: str, final_dir: str, knoten: str) -> None:
|
||||
# Die Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN
|
||||
# Worker (worker_direct), sonst an die geteilte transcode-Queue
|
||||
# (irgendein freier Worker, inkl. Remote-GPU).
|
||||
ziel_queue = _transcode_queue(knoten)
|
||||
if knoten and ziel_queue != "transcode":
|
||||
ablauf.db.add_log("info", "worker",
|
||||
f"Job {job}: Kompression gezielt an {knoten}")
|
||||
transcode_files.apply_async(args=[job, raw_dir, final_dir], queue=ziel_queue)
|
||||
|
||||
if _abbruch_angefordert(job_id):
|
||||
_job_abschliessen(job_id, {"status": "cancelled"})
|
||||
return {"status": "cancelled"}
|
||||
|
||||
disc_type = detect_disc_type(device_path)
|
||||
|
||||
if disc_type in ("no_disc", "unknown"):
|
||||
fehler = (
|
||||
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
||||
else "Disc-Typ nicht erkennbar"
|
||||
)
|
||||
db.update_job(job_id, status="failed", error=fehler, finished_at=db.utcnow())
|
||||
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
||||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||
|
||||
db.update_job(job_id, status="running", disc_type=disc_type)
|
||||
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||
|
||||
letzter = [-1]
|
||||
|
||||
def fortschritt(progress: int, message: str = ""):
|
||||
# MakeMKV liefert viele PRGV-Zeilen pro Sekunde — DB nur bei Änderung.
|
||||
if progress == letzter[0]:
|
||||
return
|
||||
letzter[0] = progress
|
||||
if _abbruch_angefordert(job_id):
|
||||
raise RipAbbruch()
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"progress": progress, "status": "ripping", "message": message},
|
||||
)
|
||||
db.update_job(job_id, progress=progress)
|
||||
|
||||
# MakeMKV-Meldungen ins Log (Befund 25.07.2026): Bis dahin überlebte NUR
|
||||
# die letzte Zeile ("Failed to open disc"), und die sagt nichts. Dass
|
||||
# MakeMKV bei der UHD-Disc nicht einmal versucht, einen Schlüssel zu
|
||||
# holen, war deshalb nur per Hand-Lauf im Container zu sehen.
|
||||
# Gedrosselt, weil das UI global nur die letzten 200 Zeilen zeigt: jede
|
||||
# Meldung höchstens einmal, insgesamt höchstens MAX_MELDUNGEN je Rip.
|
||||
# Code 1003 ist MakeMKVs eigenes DEBUG-Rauschen (am 25.07. beobachtet).
|
||||
MAX_MELDUNGEN = 40
|
||||
gesehen = set()
|
||||
|
||||
def melde_makemkv(code: int, text: str):
|
||||
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||
return
|
||||
gesehen.add(text)
|
||||
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
||||
|
||||
einstellungen = db.get_settings()
|
||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||
# Je Disc-Typ abwählbar (siehe komprimieren_fuer): 4K verlustfrei behalten,
|
||||
# DVDs trotzdem schrumpfen — vorher gab es nur alles oder nichts.
|
||||
transcode_an = ist_video and komprimieren_fuer(disc_type, einstellungen)
|
||||
|
||||
if ist_video:
|
||||
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||
_makemkv_key_anwenden(einstellungen)
|
||||
|
||||
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||||
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||||
# Serien-Rips landen stattdessen in <Serie>/Season NN (Staffel-Flow).
|
||||
job = db.get_job(job_id) or {}
|
||||
try:
|
||||
meta = json.loads(job.get("meta") or "{}")
|
||||
except ValueError:
|
||||
meta = {}
|
||||
if meta.get("series") and meta.get("season"):
|
||||
final_dir = medien.serien_ordner(
|
||||
_zielbasis(target_dir, disc_type), meta["series"], meta["season"]
|
||||
)
|
||||
else:
|
||||
final_dir = medien.zielordner(
|
||||
_zielbasis(target_dir, disc_type),
|
||||
job.get("title") or "", meta.get("year"), job_id,
|
||||
)
|
||||
|
||||
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
|
||||
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
|
||||
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
|
||||
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
|
||||
nur_hauptfilm = meta.get("main_feature_only")
|
||||
if nur_hauptfilm is None:
|
||||
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||||
if meta.get("series") or titel_liste:
|
||||
nur_hauptfilm = False
|
||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||
db.update_job(job_id, output_path=final_dir)
|
||||
|
||||
# meta["work_dir"] = die Wahl aus dem Rip-Dialog; leer = Setting/Default.
|
||||
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen, meta.get("work_dir")), job_id)
|
||||
|
||||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||
try:
|
||||
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
|
||||
except OSError:
|
||||
disc_bytes = 0
|
||||
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||
if ist_video:
|
||||
rip_ziel = raw_dir if transcode_an else final_dir
|
||||
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||||
if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
|
||||
fehler = _platz_pruefen(final_dir, disc_bytes + marge, "das Original (keepOriginal)")
|
||||
if fehler:
|
||||
ergebnis = {"status": "error", "error": fehler}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
if disc_type == "cd":
|
||||
ergebnis = rip_cd(
|
||||
device_path, job_id, progress_cb=fortschritt, output_dir=final_dir,
|
||||
auswerfen=bool(einstellungen.get("autoEject", True)),
|
||||
)
|
||||
elif transcode_an:
|
||||
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||
ergebnis = rip_video(
|
||||
device_path, job_id, disc_type,
|
||||
progress_cb=fortschritt,
|
||||
output_dir=raw_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
log_cb=melde_makemkv,
|
||||
)
|
||||
else:
|
||||
ergebnis = rip_video(
|
||||
device_path, job_id, disc_type,
|
||||
progress_cb=fortschritt, output_dir=final_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
log_cb=melde_makemkv,
|
||||
)
|
||||
|
||||
# MakeMKV-Fehler in Klartext übersetzen — "Failed to open disc" allein
|
||||
# hilft niemandem.
|
||||
if ergebnis.get("status") == "error":
|
||||
fehler_text = ergebnis.get("error") or ""
|
||||
if "volume key is unknown" in fehler_text:
|
||||
# Befund 25.07.2026, auf BEIDEN Maschinen gemessen: Laufwerk und
|
||||
# MakeMKV sind in Ordnung. makemkvcon unter Linux ruft die
|
||||
# Disc-Schlüssel schlicht nie ab — die Windows-Version tut es
|
||||
# (Meldung 3338). Hier stand vorher erst "Disc zu neu" und danach
|
||||
# "der Schlüssel-Kanal ist tot"; beides war falsch und hat in die
|
||||
# Irre geschickt. Herleitung im Kopf von makemkv_daten.py.
|
||||
speicher = makemkv_daten.schluesselspeicher_status()
|
||||
anzahl = speicher.get("schluessel", 0)
|
||||
ergebnis["error"] += (
|
||||
" — Klartext: Laufwerk und Rippy sind in Ordnung, MakeMKV "
|
||||
"liest die Disc. Es fehlt nur der Schlüssel dieser Pressung. "
|
||||
"Der Grund: makemkvcon holt Schlüssel unter Linux nie selbst "
|
||||
"nach — die Windows-Version schon. "
|
||||
+ (
|
||||
"Dieser Worker kennt aktuell GAR KEINEN Disc-Schlüssel. "
|
||||
if not anzahl
|
||||
else f"Dieser Worker kennt {anzahl} Disc-Schlüssel, "
|
||||
"diese Pressung ist nicht dabei. "
|
||||
)
|
||||
+ "Abhilfe: MakeMKV auf einem Windows-PC installieren, die "
|
||||
"Disc dort einmal öffnen, dann die Datei _private_data.tar "
|
||||
"aus dem MakeMKV-Datenverzeichnis unter Einstellungen → "
|
||||
"System hochladen. Wirkt ab dem nächsten Rip. Klappt auch "
|
||||
"das nicht, kennt MakeMKV die Pressung selbst nicht — dann "
|
||||
"hilft nur eine KEYDB.cfg (ebenfalls dort hochladbar) oder "
|
||||
"das Einreichen des AACS-Dumps im MakeMKV-Forum, Bereich "
|
||||
"'Ultra HD Blu-ray'. Der Dump steht unter Einstellungen → "
|
||||
"System zum Download bereit."
|
||||
)
|
||||
elif disc_type == "uhd" and "Failed to open disc" in fehler_text:
|
||||
ergebnis["error"] += (
|
||||
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
||||
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
||||
"(MakeMKV-Forum 'Ultimate UHD Drives Flashing Guide'). "
|
||||
"Normale BD/DVD gehen weiterhin."
|
||||
)
|
||||
|
||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||
#
|
||||
# Befund 25.07.2026: Die Einstellung („Disc nach erfolgreichem Ripping
|
||||
# automatisch auswerfen", Standard ein) wurde von NIEMANDEM gelesen. Bei
|
||||
# DVD/Blu-ray warf Rippy deshalb nie aus, bei Audio-CD dagegen immer, weil
|
||||
# abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die Einstellung beides.
|
||||
if ergebnis.get("status") == "success" and ist_video and einstellungen.get("autoEject", True):
|
||||
if wirf_disc_aus(device_path):
|
||||
db.add_log("info", "worker", f"Job {job_id}: Disc ausgeworfen")
|
||||
else:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Disc konnte nicht ausgeworfen werden ({device_path}) — "
|
||||
"der Rip ist davon unberührt.",
|
||||
)
|
||||
|
||||
if ergebnis.get("status") == "success" and transcode_an:
|
||||
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||
# freier Worker, inkl. Remote-GPU).
|
||||
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||
db.update_job(job_id, status="transcoding", progress=0)
|
||||
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||
db.add_log("info", "worker",
|
||||
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
|
||||
transcode_files.apply_async(
|
||||
args=[job_id, raw_dir, final_dir],
|
||||
queue=ziel_queue,
|
||||
)
|
||||
return {"status": "ripped", "raw_dir": raw_dir}
|
||||
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.ping_worker")
|
||||
def ping_worker():
|
||||
"""Winziger Task zum Beweisen des gezielten Routings: gibt Hostname +
|
||||
Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet
|
||||
er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI)."""
|
||||
import socket
|
||||
|
||||
import caps
|
||||
return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()}
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||
def scan_tracks(device_path: str):
|
||||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||
|
||||
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
|
||||
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
|
||||
Laufwerk). Ein Info-Lauf dauert je nach Disc 20–120 s.
|
||||
"""
|
||||
db.init_db()
|
||||
key = f"tracks:{device_path}"
|
||||
db.save_settings({"status": "running"}, key)
|
||||
try:
|
||||
titel = lies_titel_info(device_path)
|
||||
db.save_settings({"status": "done", "tracks": titel}, key)
|
||||
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
||||
return {"status": "done", "anzahl": len(titel)}
|
||||
except Exception as e:
|
||||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
|
||||
"""Hat RIPPY_PATH_MAP diesen Pfad wirklich angefasst? (pure, testbar)"""
|
||||
return container_pfad != lokal_pfad
|
||||
|
||||
|
||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||
final_container: str, final_lokal: str) -> str:
|
||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||
|
||||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||
Windows-PC des Commanders funktionierte einwandfrei — der Worker nahm die
|
||||
Aufgabe an und lehnte sie 182 ms später ab mit „Keine Roh-MKVs in
|
||||
/app/media/rippy/… gefunden". Diese Meldung klang nach einem kaputten Rip,
|
||||
obwohl der Rip vollständig war (79,6 GB lagen auf der NAS). Der Commander
|
||||
schloss daraus, das System sähe den externen Encoder nicht — verständlich
|
||||
und falsch.
|
||||
|
||||
Die eigentliche Ursache: `/app/media/...` sind CONTAINER-Pfade. Ein nativer
|
||||
Worker sieht sie nur, wenn RIPPY_PATH_MAP sie auf eine Freigabe übersetzt —
|
||||
und dieses Mapping wurde von NIEMANDEM gesetzt. Es war also nicht möglich,
|
||||
dass diese Kombination je funktioniert.
|
||||
|
||||
Zweiter Stolperstein im selben Fall: Die Rohdaten lagen auf der NAS (für den
|
||||
PC erreichbar), das ZIEL aber auf der VM-Platte (nicht erreichbar, dort
|
||||
läuft kein Samba). Deshalb werden hier BEIDE Pfade geprüft, nicht nur die
|
||||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||
"""
|
||||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||
fremder_worker = not os.path.isdir("/app")
|
||||
|
||||
for zweck, container, lokal in (
|
||||
("Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||
("Ziel (fertige Datei)", final_container, final_lokal),
|
||||
):
|
||||
if os.path.isdir(lokal):
|
||||
continue
|
||||
# Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird
|
||||
# gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem.
|
||||
if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."):
|
||||
continue
|
||||
|
||||
text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"]
|
||||
if fremder_worker and not mapping:
|
||||
text.append(
|
||||
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
|
||||
f'„{container}" ist ein Pfad INNERHALB des Rippy-Containers — ein '
|
||||
'externer Worker sieht ihn nur, wenn er auf eine Netzwerk-Freigabe '
|
||||
'übersetzt wird.'
|
||||
)
|
||||
text.append(
|
||||
"Abhilfe: Freigabe auf diesem Rechner erreichbar machen und in "
|
||||
"start-tray.bat setzen, z. B. "
|
||||
"set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
|
||||
)
|
||||
elif fremder_worker and mapping:
|
||||
text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.")
|
||||
if not _ist_uebersetzt(container, lokal):
|
||||
text.append(
|
||||
f'„{container}" wurde von keinem Eintrag übersetzt — fehlt ein '
|
||||
'Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
|
||||
)
|
||||
else:
|
||||
text.append(
|
||||
"Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer "
|
||||
"Freigabe, die gerade nicht eingehängt ist?"
|
||||
)
|
||||
text.append(
|
||||
'WICHTIG: Die Rohdaten sind NICHT verloren — nach der Korrektur '
|
||||
'genügt „Neu komprimieren", ohne die Disc erneut zu rippen.'
|
||||
)
|
||||
return " ".join(text)
|
||||
return ""
|
||||
return ablauf.rippen(device_path, job_id, target_dir,
|
||||
melde=melde, weiterreichen=weiterreichen)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
||||
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||
"""Komprimiert die Rohdaten. Der Ablauf steht in ablauf.komprimieren()."""
|
||||
return ablauf.komprimieren(job_id, raw_dir, final_dir)
|
||||
|
||||
Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis
|
||||
gelöscht — bricht die Kompression ab, bleibt das Original in /app/temp
|
||||
liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530).
|
||||
Über POST /jobs/{id}/retry-transcode jederzeit neu anstoßbar.
|
||||
"""
|
||||
db.init_db()
|
||||
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
||||
raw_container, final_container = raw_dir, final_dir
|
||||
raw_dir = pfad_lokal(raw_dir)
|
||||
final_dir = pfad_lokal(final_dir)
|
||||
|
||||
fehler = _erreichbarkeit_pruefen(raw_container, raw_dir, final_container, final_dir)
|
||||
if fehler:
|
||||
ergebnis = {"status": "error", "error": fehler}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||
def scan_tracks(device_path: str):
|
||||
return ablauf.scan_tracks(device_path)
|
||||
|
||||
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
|
||||
if not quellen:
|
||||
ergebnis = {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"Das Verzeichnis {raw_dir} ist erreichbar, enthält aber keine "
|
||||
"MKV-Datei. Der Rip hat also nichts abgelegt (oder jemand hat die "
|
||||
"Datei entfernt). Die Kompression lässt sich nach einem neuen Rip "
|
||||
"erneut anstoßen."
|
||||
),
|
||||
}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
einstellungen = db.get_settings()
|
||||
# Preset nach Disc-Typ (Befund 25.07.2026): vorher lief JEDE Quelle durch
|
||||
# dasselbe Preset — eine 4K-UHD wurde damit auf 1080p heruntergerechnet.
|
||||
job = db.get_job(job_id) or {}
|
||||
disc_type = job.get("disc_type") or ""
|
||||
preset = preset_fuer(disc_type, einstellungen)
|
||||
original_behalten = einstellungen.get("keepOriginal", False)
|
||||
@celery_app.task(name="worker.tasks.ping_worker")
|
||||
def ping_worker():
|
||||
return ablauf.ping_worker()
|
||||
|
||||
os.makedirs(final_dir, exist_ok=True)
|
||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
||||
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
|
||||
)
|
||||
|
||||
anzahl = len(quellen)
|
||||
letzter = [-1]
|
||||
letzte_abbruchpruefung = [0.0]
|
||||
|
||||
def abbruch_pruefen():
|
||||
"""Zeitgesteuert prüfen, ob der Nutzer abgebrochen hat. Wirft RipAbbruch.
|
||||
|
||||
Befund 25.07.2026 (Commander, am laufenden Akira-Job beobachtet): Der
|
||||
Abbruch wurde NUR in `datei_fortschritt` geprüft — und diese Closure
|
||||
stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geändert
|
||||
hatte. Bei einem 4K-Encode, der pro Prozent eine halbe Stunde braucht,
|
||||
sah „Abbrechen" entsprechend lange wirkungslos aus (gemessen: 3,4 min
|
||||
zwischen Anforderung 18:30:17 und Bestätigung 18:33:38 — bei noch
|
||||
langsamerem Fortschritt entsprechend mehr).
|
||||
|
||||
Deshalb hängt die Prüfung jetzt an der Zeit statt am Fortschritt und
|
||||
läuft bei JEDER Ausgabezeile von HandBrake — auch während des
|
||||
Scan-Durchlaufs, der überhaupt keine Encode-Prozente liefert.
|
||||
"""
|
||||
jetzt = time.monotonic()
|
||||
if jetzt - letzte_abbruchpruefung[0] < ABBRUCH_INTERVALL_SEKUNDEN:
|
||||
return
|
||||
letzte_abbruchpruefung[0] = jetzt
|
||||
if _abbruch_angefordert(job_id):
|
||||
raise RipAbbruch()
|
||||
|
||||
for index, quelle in enumerate(quellen):
|
||||
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||||
|
||||
def datei_fortschritt(p, _index=index):
|
||||
gesamt = int((_index * 100 + p) / anzahl)
|
||||
if gesamt == letzter[0]:
|
||||
return
|
||||
letzter[0] = gesamt
|
||||
db.update_job(job_id, progress=min(99, gesamt))
|
||||
|
||||
hb = run_handbrake(
|
||||
quelle, ziel, preset=preset,
|
||||
progress_cb=datei_fortschritt,
|
||||
abbruch_cb=abbruch_pruefen,
|
||||
)
|
||||
if hb.get("status") == "cancelled":
|
||||
_job_abschliessen(job_id, hb)
|
||||
return hb
|
||||
if hb.get("status") != "success":
|
||||
ergebnis = {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
||||
),
|
||||
}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
if original_behalten:
|
||||
_original_aufheben(job_id, raw_dir, final_dir)
|
||||
else:
|
||||
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||
|
||||
ergebnis = {"status": "success", "output_dir": final_dir}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
# Der Anzeigename dieses Knotens — wird vom Herzschlag in celery_app.py
|
||||
# benutzt und stand bisher hier.
|
||||
WORKER_NAME = os.getenv("WORKER_NAME", "")
|
||||
|
||||
@@ -138,3 +138,109 @@ def test_simd_stufe_der_rippy_vm_ist_sse4_2():
|
||||
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
|
||||
)
|
||||
assert caps.beste_simd(flags_vm) == "sse4_2"
|
||||
|
||||
|
||||
# --- Preset-Liste: die Namen, die HandBrake wirklich kennt -------------------
|
||||
|
||||
# Wörtlich aus `HandBrakeCLI --preset-list` im Worker-Image (26.07.2026,
|
||||
# HandBrake 1.6.1) — gekürzt auf je zwei Presets pro Kategorie, Struktur und
|
||||
# Einrückung unverändert. Die zwei Logzeilen oben stehen dort wirklich mit
|
||||
# drin (HandBrake schreibt sie in denselben Strom) und müssen wegfallen.
|
||||
HB_PRESETS_ECHT = """[10:26:05] Compile-time hardening features are enabled
|
||||
[10:26:05] qsv: not available on this system
|
||||
Cannot load libnvidia-encode.so.1
|
||||
General/
|
||||
Very Fast 2160p60 4K AV1
|
||||
AV1 video (up to 2160p60) and AAC stereo audio, in an MP4
|
||||
container.
|
||||
Super HQ 1080p30 Surround
|
||||
High quality H.264 video (up to 1080p30) and high bit rate AAC
|
||||
surround audio, in an MP4 container.
|
||||
Matroska/
|
||||
H.265 MKV 2160p60 4K
|
||||
H.265 video (up to 2160p60) and surround audio, in an MKV
|
||||
container.
|
||||
H.265 MKV 1080p30
|
||||
H.265 video (up to 1080p30) and surround audio, in an MKV
|
||||
container.
|
||||
Hardware/
|
||||
AV1 QSV 2160p 4K
|
||||
AV1 video (up to 2160p) and surround audio, in an MP4 container.
|
||||
H.265 VCN 2160p 4K
|
||||
H.265 video (up to 2160p) and surround audio, in an MP4 container.
|
||||
CLI Defaults/
|
||||
CLI Default
|
||||
"""
|
||||
|
||||
|
||||
def test_preset_liste_kommt_aus_der_einrueckung():
|
||||
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||
assert presets == [
|
||||
"Very Fast 2160p60 4K AV1",
|
||||
"Super HQ 1080p30 Surround",
|
||||
"H.265 MKV 2160p60 4K",
|
||||
"H.265 MKV 1080p30",
|
||||
"AV1 QSV 2160p 4K",
|
||||
"H.265 VCN 2160p 4K",
|
||||
"CLI Default",
|
||||
]
|
||||
|
||||
|
||||
def test_preset_liste_nimmt_keine_beschreibung_und_kein_log():
|
||||
"""Beschreibungen (8 Leerzeichen), Kategorien (0) und HandBrakes eigene
|
||||
Logzeilen (0) dürfen nicht als Preset-Namen durchgehen."""
|
||||
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||
assert not any(p.endswith("/") for p in presets)
|
||||
assert not any("container" in p for p in presets)
|
||||
assert not any(p.startswith("[") for p in presets)
|
||||
assert "Cannot load libnvidia-encode.so.1" not in presets
|
||||
|
||||
|
||||
def test_preset_liste_leer_bleibt_leer():
|
||||
assert caps.parse_preset_liste("") == []
|
||||
assert caps.parse_preset_liste(None) == []
|
||||
|
||||
|
||||
def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
||||
"""Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmöglich, die
|
||||
Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein
|
||||
Hardware-Encoder läuft. Die Messung sagt das Gegenteil — `--preset-list`
|
||||
nennt sie, `--help` nennt nur die nutzbaren Encoder. Zwei Fragen, zwei
|
||||
Quellen."""
|
||||
presets = caps.parse_preset_liste(HB_PRESETS_ECHT)
|
||||
assert "H.265 VCN 2160p 4K" in presets # AMD, aus der VM-Liste
|
||||
assert "AV1 QSV 2160p 4K" in presets # Intel, aus der VM-Liste
|
||||
# ... und dieselbe Maschine meldet trotzdem KEINEN Hardware-Encoder:
|
||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||
]
|
||||
|
||||
|
||||
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||
#
|
||||
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||
# Freigabe gibt.
|
||||
|
||||
|
||||
def test_im_container_ist_nichts_extern():
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({}, container=True) == "nein"
|
||||
|
||||
|
||||
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||
|
||||
|
||||
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||
container=False) == "ja"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests für die Erreichbarkeits-Prüfung vor der Kompression.
|
||||
|
||||
Diese Prüfung hatte KEINEN einzigen Test — und genau deshalb ist sie am
|
||||
26.07.2026 im ersten vollständigen Durchlauf umgefallen: Der Rip lief sauber
|
||||
durch (40 GB Akira), die Kompression brach 0,2 s später ab mit „Dieser Worker
|
||||
erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
|
||||
zwei noch nie angelegte Ordner.
|
||||
"""
|
||||
|
||||
import ablauf as tasks
|
||||
|
||||
|
||||
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
||||
|
||||
def _ohne_schluss_trenner(p):
|
||||
"""`\\\\NAS\\rippy\\` und `\\\\NAS\\rippy` sind derselbe Ordner.
|
||||
|
||||
Wichtig, weil `ntpath.dirname` für die UNC-Wurzel MIT abschließendem
|
||||
Backslash zurückkommt. Das echte `os.path.isdir` stört das nicht — eine
|
||||
Attrappe, die stur vergleicht, hätte hier einen Fehler erfunden, den es
|
||||
nicht gibt.
|
||||
"""
|
||||
return p.rstrip("\\/") or p
|
||||
|
||||
|
||||
def _fake_isdir(vorhandene):
|
||||
"""isdir-Ersatz, der nur die genannten Pfade kennt."""
|
||||
bekannt = {_ohne_schluss_trenner(p) for p in vorhandene}
|
||||
return lambda p: _ohne_schluss_trenner(p) in bekannt
|
||||
|
||||
|
||||
def test_findet_den_naechsten_vorhandenen_ordner():
|
||||
da = {"\\\\NAS\\rippy"}
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||
isdir=_fake_isdir(da), dirname=lambda p: p.rsplit("\\", 1)[0] if "\\" in p.strip("\\") else "",
|
||||
) == "\\\\NAS\\rippy"
|
||||
|
||||
|
||||
def test_zwei_fehlende_ebenen_sind_in_ordnung():
|
||||
"""DER Vorfall: `movies` UND der Filmordner fehlten beide.
|
||||
|
||||
`os.makedirs` legt die ganze Kette an — es genügt also, dass irgendein
|
||||
Vorfahre existiert. Die alte Prüfung sah nur nach `dirname` und erklärte
|
||||
damit jeden ERSTEN Film in einer neuen Ablage für unerreichbar.
|
||||
"""
|
||||
da = {"/share"}
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"/share/movies/Akira (1988)", isdir=_fake_isdir(da)) == "/share"
|
||||
|
||||
|
||||
def test_gar_nichts_erreichbar_gibt_leer():
|
||||
"""Ist die Freigabe wirklich weg, muss die Prüfung anschlagen."""
|
||||
assert tasks.erster_vorhandener_ordner(
|
||||
"/share/movies/Film", isdir=_fake_isdir(set())) == ""
|
||||
|
||||
|
||||
def test_sucht_nicht_endlos_auf_einer_toten_freigabe():
|
||||
"""Jeder Schritt kostet auf einer toten Freigabe einen Timeout."""
|
||||
gefragt = []
|
||||
|
||||
def zaehlend(p):
|
||||
gefragt.append(p)
|
||||
return False
|
||||
|
||||
tiefer_pfad = "/" + "/".join(f"e{i}" for i in range(50))
|
||||
assert tasks.erster_vorhandener_ordner(tiefer_pfad, isdir=zaehlend) == ""
|
||||
assert len(gefragt) <= tasks.MAX_ELTERN_STUFEN
|
||||
|
||||
|
||||
def test_leerer_pfad_stuerzt_nicht_ab():
|
||||
assert tasks.erster_vorhandener_ordner("", isdir=_fake_isdir(set())) == ""
|
||||
|
||||
|
||||
# --- Die Meldung: eine falsche Ursache ist teurer als gar keine -------------
|
||||
|
||||
def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
||||
import ntpath
|
||||
kennt = _fake_isdir(vorhandene)
|
||||
monkeypatch.setattr(tasks.os.path, "isdir",
|
||||
lambda p: kennt(p) or (im_container and p == "/app"))
|
||||
monkeypatch.setattr(tasks.os.path, "dirname", ntpath.dirname)
|
||||
monkeypatch.setenv("RIPPY_PATH_MAP", mapping)
|
||||
return tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||
fremd=not im_container,
|
||||
)
|
||||
|
||||
|
||||
def test_alles_da_meldet_nichts(monkeypatch):
|
||||
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy\\movies\\Akira (1988)"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy") == ""
|
||||
|
||||
|
||||
def test_ziel_fehlt_ganz_ist_kein_fehler(monkeypatch):
|
||||
"""Der Vorfall vom 26.07.2026 — jetzt geht er durch."""
|
||||
assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy") == ""
|
||||
|
||||
|
||||
def test_fehlende_quelle_bleibt_ein_fehler(monkeypatch):
|
||||
meldung = _pruefen(monkeypatch, {"\\\\NAS\\rippy"}, "/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "Quelle" in meldung
|
||||
assert "Rohdaten sind NICHT verloren" in meldung
|
||||
|
||||
|
||||
def test_uebersetzter_aber_unerreichbarer_pfad_beschuldigt_nicht_die_karte(monkeypatch):
|
||||
"""⚠️ Genau die Falschaussage aus dem Vorfall.
|
||||
|
||||
Die Meldung lautete „RIPPY_PATH_MAP … deckt diesen Pfad aber nicht ab",
|
||||
während sie ihn im selben Satz korrekt übersetzt hatte. Wer dem folgte,
|
||||
suchte in der Karte statt in der Freigabe.
|
||||
"""
|
||||
meldung = _pruefen(monkeypatch, set(), "/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "deckt diesen Pfad aber nicht ab" not in meldung
|
||||
assert "Übersetzt wurde er korrekt" in meldung
|
||||
assert "Freigabe verbunden" in meldung
|
||||
|
||||
|
||||
def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
||||
"""Hier ist die Karte wirklich schuld — der Pfad blieb unverändert."""
|
||||
monkeypatch.setattr(tasks.os.path, "isdir", lambda p: False)
|
||||
monkeypatch.setenv("RIPPY_PATH_MAP", "/app/temp=Y:\\temp")
|
||||
meldung = tasks._erreichbarkeit_pruefen(
|
||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||
"/app/media/movies/X", "/app/media/movies/X",
|
||||
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||
fremd=True,
|
||||
)
|
||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||
assert "von keinem Eintrag übersetzt" in meldung
|
||||
|
||||
|
||||
def test_ohne_mapping_nennt_die_wahre_ursache(monkeypatch):
|
||||
meldung = _pruefen(monkeypatch, set(), "")
|
||||
assert "nicht gesetzt" in meldung
|
||||
assert "RIPPY_PATH_MAP" in meldung
|
||||
|
||||
|
||||
def test_artikel_stimmt_in_beiden_meldungen(monkeypatch):
|
||||
"""„erreicht die Ziel" stand wörtlich im Log des Commanders."""
|
||||
quelle = _pruefen(monkeypatch, set(), "")
|
||||
assert "erreicht die Quelle" in quelle
|
||||
# Quelle da, Ziel unerreichbar (kein einziger Vorfahre) → Ziel-Meldung
|
||||
ziel = _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1"},
|
||||
"/app/media/rippy=\\\\NAS\\rippy")
|
||||
assert "erreicht das Ziel" in ziel
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr.
|
||||
|
||||
Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen
|
||||
(26.07.2026, Celery 5.4.0) — AGENTS Regel D. Erfundene Formate hätten hier
|
||||
keinen Wert: Genau am geratenen Format scheitert so eine Brücke.
|
||||
"""
|
||||
|
||||
import logbruecke
|
||||
|
||||
# Aus `docker compose logs worker`, unverändert.
|
||||
ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0"
|
||||
ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors"
|
||||
ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone"
|
||||
ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready."
|
||||
ECHT_WARNUNG = (
|
||||
"[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: "
|
||||
"{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}"
|
||||
)
|
||||
ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)"
|
||||
|
||||
|
||||
def test_ready_ist_die_meldung_auf_die_man_wartet():
|
||||
"""Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will —
|
||||
deshalb als Erfolg, nicht als Info im Grundrauschen."""
|
||||
assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.")
|
||||
|
||||
|
||||
def test_verbindung_kommt_durch():
|
||||
level, text = logbruecke.einordnen(ECHT_CONNECTED)
|
||||
assert level == "info"
|
||||
assert "redis" in text
|
||||
|
||||
|
||||
def test_warnungen_und_fehler_kommen_immer_durch():
|
||||
level, text = logbruecke.einordnen(ECHT_WARNUNG)
|
||||
assert level == "warning"
|
||||
assert "Zombie-Erkennung" in text
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error"
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error"
|
||||
|
||||
|
||||
def test_grundrauschen_wird_verworfen():
|
||||
"""Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine
|
||||
automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins."""
|
||||
assert logbruecke.einordnen(ECHT_MINGLE) is None
|
||||
assert logbruecke.einordnen(ECHT_ALLEIN) is None
|
||||
assert logbruecke.einordnen(ECHT_BANNER) is None
|
||||
assert logbruecke.einordnen("") is None
|
||||
assert logbruecke.einordnen(None) is None
|
||||
assert logbruecke.einordnen(
|
||||
"[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None
|
||||
|
||||
|
||||
def test_absturz_ohne_celery_klammer_kommt_durch():
|
||||
"""Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was
|
||||
passieren kann."""
|
||||
assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error"
|
||||
assert logbruecke.einordnen(
|
||||
"ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error"
|
||||
|
||||
|
||||
def test_quelle_wird_auf_32_zeichen_gekappt():
|
||||
"""`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei
|
||||
einem langen Worker-Namen — und dann fehlt genau das Log, das man sucht."""
|
||||
assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc"
|
||||
lang = logbruecke.quelle_fuer("x" * 100)
|
||||
assert len(lang) == 32
|
||||
assert lang.startswith("w:")
|
||||
assert logbruecke.quelle_fuer("") == "w:worker"
|
||||
assert logbruecke.quelle_fuer(None) == "w:worker"
|
||||
|
||||
|
||||
# --- Drosselung -------------------------------------------------------------
|
||||
|
||||
|
||||
def _bruecke(uhr):
|
||||
geschrieben = []
|
||||
b = logbruecke.Bruecke(
|
||||
"testpc",
|
||||
schreiber=lambda level, quelle, text: geschrieben.append((level, text)),
|
||||
jetzt=lambda: uhr[0],
|
||||
)
|
||||
return b, geschrieben
|
||||
|
||||
|
||||
def test_drosselung_haelt_bei_30_zeilen_die_minute():
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for i in range(40):
|
||||
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||
assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE
|
||||
|
||||
|
||||
def test_naechste_minute_meldet_wieviel_verschluckt_wurde():
|
||||
"""Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon
|
||||
einmal eine Stunde gekostet hat."""
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for i in range(40):
|
||||
b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}")
|
||||
uhr[0] = 61.0
|
||||
b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler")
|
||||
|
||||
unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t]
|
||||
assert len(unterdrueckt) == 1
|
||||
assert "10 weitere" in unterdrueckt[0]
|
||||
|
||||
|
||||
def test_kaputter_schreiber_bringt_den_worker_nicht_um():
|
||||
"""Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der
|
||||
Worker muss weiterarbeiten."""
|
||||
def kaputt(level, quelle, text):
|
||||
raise RuntimeError("Datenbank weg")
|
||||
|
||||
b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0)
|
||||
assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False
|
||||
|
||||
|
||||
def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel():
|
||||
uhr = [0.0]
|
||||
b, geschrieben = _bruecke(uhr)
|
||||
for _ in range(100):
|
||||
b.zeile(ECHT_MINGLE)
|
||||
b.zeile(ECHT_READY)
|
||||
assert geschrieben == [("success", "celery@ced31798928d ready.")]
|
||||
@@ -105,7 +105,7 @@ def test_matche_episoden_ohne_treffer_gibt_none():
|
||||
|
||||
|
||||
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||
from tasks import pfad_lokal
|
||||
from ablauf import pfad_lokal
|
||||
|
||||
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
||||
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
||||
@@ -121,30 +121,39 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||
|
||||
|
||||
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||
# prompt rot geworden).
|
||||
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||
|
||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
||||
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||
niemand gefragt wird.
|
||||
"""
|
||||
import tasks
|
||||
import ablauf as tasks
|
||||
|
||||
einst = {"workDir": "/app/media/movies"}
|
||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
||||
w = CONTAINER
|
||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
||||
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||
|
||||
|
||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
||||
import tasks
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
||||
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
|
||||
|
||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||
import tasks
|
||||
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||
Windows-Reparatur nichts."""
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
||||
w = CONTAINER
|
||||
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
||||
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||
import tasks
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
||||
w = CONTAINER
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||
|
||||
|
||||
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||
#
|
||||
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||
# sogar eine AUSDRUECKLICHE Wahl.
|
||||
#
|
||||
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||
# ignorierte ihn — ohne ein Wort.
|
||||
|
||||
|
||||
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||
deshalb still verworfen."""
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||
|
||||
|
||||
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||
|
||||
|
||||
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||
Wurzel."""
|
||||
import ablauf as tasks
|
||||
|
||||
unc = r"\\192.168.179.62\rippy\movies"
|
||||
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||
|
||||
|
||||
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||
import ablauf as tasks
|
||||
|
||||
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||
assert ziel.startswith(NATIV[0])
|
||||
assert "/app/" not in ziel
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user