Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84c5ee399 | ||
|
|
fbedb9946d | ||
|
|
eb54c59141 | ||
|
|
0088948b4f | ||
|
|
d8d34e8fbb | ||
|
|
5353b381ba | ||
|
|
9d16e82b5f | ||
|
|
d45760018e | ||
|
|
e7e33e9021 | ||
|
|
fc2d16ca38 | ||
|
|
0fc87aa022 | ||
|
|
6b6e5c3436 | ||
|
|
ae82d8c0fd | ||
|
|
3dacd7cb4f | ||
|
|
c1ee339446 | ||
|
|
783b8e4897 | ||
|
|
551f1d7b2c | ||
|
|
904e4b2ef6 | ||
|
|
8c4456d005 | ||
|
|
12b9720386 | ||
|
|
6f1add480e | ||
|
|
473eea7d4e | ||
|
|
687173430c | ||
|
|
8f671ff160 | ||
|
|
512b38cced | ||
|
|
f5dab5d1c1 | ||
|
|
76ed3b337a | ||
|
|
2ae79fceb4 | ||
|
|
3dc6798c79 | ||
|
|
6678c81592 | ||
|
|
2c74dc6958 | ||
|
|
6555562a4c | ||
|
|
c46a280b1f | ||
|
|
6dd1b0bc7a | ||
|
|
e3ce8220f1 | ||
|
|
3f7f962dff | ||
|
|
d70a81d194 | ||
|
|
7c489f8404 | ||
|
|
1f678c036d | ||
|
|
6d149212c9 | ||
|
|
9622f4e6ef | ||
|
|
306b090ccf | ||
|
|
7ce06889f8 | ||
|
|
043052b453 | ||
|
|
a8e8b2bef3 | ||
|
|
1253148652 | ||
|
|
2549b9643b | ||
|
|
663127f854 | ||
|
|
45402e887e | ||
|
|
7a4f92036d | ||
|
|
051cab3afa | ||
|
|
1d53e8526f | ||
|
|
827910fd63 | ||
|
|
0c38754c09 | ||
|
|
c0a7618e33 | ||
|
|
7d496cf654 | ||
|
|
4f943e2c4f | ||
|
|
38a3a87f70 | ||
|
|
673f7ff3e7 | ||
|
|
5522a50425 | ||
|
|
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 |
@@ -29,11 +29,21 @@ Thumbs.db
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
.docker/
|
.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/
|
||||||
|
**/tests/
|
||||||
test_*.py
|
test_*.py
|
||||||
|
**/test_*.py
|
||||||
*_test.py
|
*_test.py
|
||||||
|
**/*_test.py
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/__pycache__/
|
||||||
|
conftest.py
|
||||||
|
**/conftest.py
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ jobs:
|
|||||||
|
|
||||||
if [ -n "$pkg_dateien" ]; then
|
if [ -n "$pkg_dateien" ]; then
|
||||||
echo "== Node/TypeScript erkannt =="
|
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
|
while IFS= read -r pkg; do
|
||||||
[ -n "$pkg" ] || continue
|
[ -n "$pkg" ] || continue
|
||||||
d=$(dirname "$pkg")
|
d=$(dirname "$pkg")
|
||||||
|
|||||||
@@ -54,3 +54,7 @@ logs/
|
|||||||
# Data
|
# Data
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.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.
|
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||||
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
(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
|
## Workflow
|
||||||
|
|
||||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
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 Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||||
|
|
||||||
## Aktueller Stand (25.07.2026)
|
## Aktueller Stand (26.07.2026)
|
||||||
|
|
||||||
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
- ✅ **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,
|
- ✅ **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
|
- ✅ **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),
|
(Ping im Hintergrund), Kompression je Disc-Typ abwählbar (4K verlustfrei),
|
||||||
Wizard empfiehlt nach gemessener CPU, vier tote Routen entfernt
|
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
|
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||||
|
|
||||||
## Was diese Sitzungen wiederholt gekostet hat
|
## 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
|
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
|
||||||
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
|
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
|
||||||
vorherzusagen.
|
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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||||
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
| ~~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 | | |
|
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||||
| Proxmox LXC Template + Ansible Playbooks | ✔ 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
|
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||||
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||||
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
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
|
- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
|
||||||
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
|
||||||
unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
|
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
|
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||||
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||||
EINDEUTIGER Zuordnung wird umbenannt).
|
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**.
|
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
|
```bash
|
||||||
git clone <repo-url> rippy && cd rippy
|
git clone <repo-url> rippy && cd rippy
|
||||||
sudo ./install.sh
|
sudo ./install.sh
|
||||||
|
|||||||
@@ -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
|
## 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
|
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
|
||||||
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
|
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
|
||||||
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
|
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
|
||||||
|
|||||||
@@ -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,402 @@
|
|||||||
|
# Durchsicht Rippy v5 (5.6.1) — Bericht vom 12.09.2026
|
||||||
|
|
||||||
|
> Anlass: Drei Filme (Der Patriot, Déjà Vu, Training Day) lassen sich auf
|
||||||
|
> einem ANDEREN PC mit Rippy nicht bearbeiten, während MakeMKV allein sie
|
||||||
|
> lesen und öffnen kann. Dazu die Bitte um einen kompletten Check von
|
||||||
|
> Kern, Haupt und Fenster.
|
||||||
|
>
|
||||||
|
> Was hier steht, ist GEMESSEN oder im Quelltext mit Zeile belegt — nichts
|
||||||
|
> ist geraten. Wo ich etwas nicht messen konnte, steht das dabei.
|
||||||
|
|
||||||
|
Stand des Repos: Branch `worktree-windows-electron`, Spitze `473eea7`,
|
||||||
|
Arbeitsbaum sauber. `npm run typecheck` grün, `npm test` grün
|
||||||
|
(450 Tests bestanden, 12 übersprungen — die Messungen, die ein Laufwerk
|
||||||
|
brauchen). Gelesen wurden ALLE 92 Quelldateien unter `rippy-windows/src`
|
||||||
|
(16 081 Zeilen), die Tests, die Bau-Konfiguration und die Protokolle des
|
||||||
|
echten Rippy auf diesem PC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die drei Filme — was am anderen PC dahintersteckt
|
||||||
|
|
||||||
|
Ich habe die Disc-Ausgaben des anderen PCs nicht; ich habe aber ALLE Wege
|
||||||
|
gefunden, auf denen Rippy scheitert, während MakeMKV allein weiterkommt.
|
||||||
|
Sie stehen in der Reihenfolge, in der ich sie für wahrscheinlich halte —
|
||||||
|
mit der Zeile, an der man sie am anderen PC in einer Minute erkennt.
|
||||||
|
|
||||||
|
**Die Protokoll-Datei liegt dort unter `%APPDATA%\Rippy\logs\rippy-JJJJ-MM-TT.log`**
|
||||||
|
(im Programm: Einstellungen → Allgemein → „Protokoll öffnen"). Die Zeilen
|
||||||
|
des Tages, an dem einer der drei Filme versucht wurde, entscheiden.
|
||||||
|
|
||||||
|
### Kandidat 1 — MakeMKV läuft dort ohne gültigen Schlüssel (Beta-Key-Fehler in Rippy)
|
||||||
|
|
||||||
|
Der frei veröffentlichte Beta-Key vom September 2026 enthält ein `@`:
|
||||||
|
`T-vwvn…6Ox4BI@N5rPp` (68 Zeichen, heute von der Herstellerseite geladen,
|
||||||
|
„valid until end of September 2026"). Rippys Muster
|
||||||
|
`KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/` (`kern/werkzeuge/schluessel.ts:19`)
|
||||||
|
kennt kein `@` und schneidet den Schlüssel auf 62 Zeichen ab (Sonde 9,
|
||||||
|
gemessen an der echten Seite). Den verstümmelten Schlüssel lehnt MakeMKV
|
||||||
|
ab (5020), Rippy nimmt ihn „damit MakeMKV im Beta-Modus weiterläuft" wieder
|
||||||
|
zurück (`schluessel.ts:214-219`) — und MakeMKV läuft ohne Schlüssel.
|
||||||
|
Für DVDs ist das egal (laut Hersteller dauerhaft frei), **für Blu-rays
|
||||||
|
schaltet MakeMKV nach Ablauf des Testzeitraums ab** — nach der
|
||||||
|
Gnadenfrist („noch einige Tage", MSG 5051) beim Speichern, je nach
|
||||||
|
Fassung auch beim Öffnen. Ob das GUI am anderen PC noch in der
|
||||||
|
Gnadenfrist ist, sagt dessen Startmeldung; Rippy dagegen meldet den
|
||||||
|
Zustand nicht als Ausfall (siehe F1/F5).
|
||||||
|
|
||||||
|
Warum es bei DIR läuft: In der echten Datenbank dieses PCs steht unter
|
||||||
|
`makemkvAppKey` ein 68-Zeichen-Schlüssel MIT `@` — du hast den Key von
|
||||||
|
Hand als „Eigener Schlüssel" eingetragen. Der andere PC verlässt sich
|
||||||
|
vermutlich auf die Automatik, und die liefert den kaputten Key.
|
||||||
|
|
||||||
|
*Erkennen:* Einstellungen → MakeMKV zeigt dort gelb „BETA-MODUS — RIPS
|
||||||
|
LAUFEN" oder rot; im Protokoll steht `Schlüssel: Beta-Key abgelehnt … —
|
||||||
|
wieder entfernt`; ein gescheiterter Rip trägt `Ursache: MakeMKV ist nicht
|
||||||
|
freigeschaltet (Testzeitraum abgelaufen)`; MakeMKV-GUI zeigt beim Start
|
||||||
|
„Der Testzeitraum ist abgelaufen".
|
||||||
|
*Sofort-Abhilfe ohne neue Version:* den vollständigen Key (mit `@`) von
|
||||||
|
Hand unter Einstellungen → MakeMKV → „Eigener Schlüssel" eintragen,
|
||||||
|
„Schlüssel neu prüfen". Gilt bis 30.09.2026 — danach braucht es den
|
||||||
|
Rippy-Fix (Fund 1 unten) oder wieder Handarbeit.
|
||||||
|
|
||||||
|
### Kandidat 2 — Titel lesen bricht nach fünf Minuten ab (der Timeout, nicht die Disc)
|
||||||
|
|
||||||
|
`erkennung.titelLesen` tötet `makemkvcon info` nach 300 s
|
||||||
|
(`kern/dienste/erkennung.ts:315`, `kern/rip/makemkv.ts:170-173`) und
|
||||||
|
meldet „MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer
|
||||||
|
beschädigten Disc kann das passieren". Ältere DVDs mit Kopierschutz
|
||||||
|
(Sony-ARccOS, Disney-99-Titel-Struktur) und Discs mit vielen Playlisten
|
||||||
|
brauchen im MakeMKV-GUI regelmäßig länger als fünf Minuten zum Öffnen —
|
||||||
|
das GUI wartet, Rippy nicht. Ein hart beendetes makemkvcon hinterlässt
|
||||||
|
außerdem gern den 5042-Zustand („findet kein Laufwerk"), sodass der
|
||||||
|
NÄCHSTE Versuch sofort scheitert.
|
||||||
|
*Erkennen:* `Titel-Lauf X gescheitert: MakeMKV hat die Titel nach fünf
|
||||||
|
Minuten nicht gelesen`; im GUI dauert „Disc wird geöffnet" länger als 5 min.
|
||||||
|
|
||||||
|
### Kandidat 3 — der Rip startet still nicht (Ablage oder Arbeitsordner nicht erreichbar)
|
||||||
|
|
||||||
|
`Pipeline.starten` legt den Roh-Ordner mit `mkdirSync` an
|
||||||
|
(`kern/ablauf/pipeline.ts:258`) — ohne Fangnetz. Ist die Ablage oder der
|
||||||
|
Arbeitsordner gerade nicht da (NAS aus, Netzlaufwerk noch nicht
|
||||||
|
verbunden, Buchstabe weg), fliegt eine Ausnahme aus `starten()`, der
|
||||||
|
Aufruf `void laufwerke.bereit.then(() => ripVerwaltung.starten(…))`
|
||||||
|
(`kern/index.ts:198`) verschluckt sie: **keine Meldung im Fenster, keine
|
||||||
|
Zeile im Protokoll, der Dialog schließt sich, nichts passiert** (Sonde 10:
|
||||||
|
`ENOENT … mkdir 'Q:\rippy-nicht-da\roh\…'`, gemeldet: nichts). Der Kern
|
||||||
|
stirbt dabei nicht (in Electron 44 gemessen: ein utilityProcess überlebt
|
||||||
|
eine unbehandelte Promise-Ablehnung) — er schweigt nur.
|
||||||
|
*Erkennen:* Nach `Titel-Lauf X: … s, N Titel` folgt KEINE Zeile
|
||||||
|
`Vorgang … beginnt`; die Kachel bleibt ohne Balken.
|
||||||
|
|
||||||
|
### Kandidat 4 — Disc-Typ „unbekannt" → Rippy sichert ein ISO statt zu rippen
|
||||||
|
|
||||||
|
Der Typ kommt aus `IOCTL_CDROM_DISK_TYPE` + Größe (`kern/laufwerk/disc.ts:52-59`).
|
||||||
|
Antwortet das Laufwerk darauf nicht (bei manchen USB-Brücken
|
||||||
|
`ERROR_INVALID_FUNCTION`) oder ohne Bits, steht `unknown` — die Kachel
|
||||||
|
zeigt „unbekannt" und PRESET „ISO-Abbild", der Knopf „Rippen" öffnet
|
||||||
|
trotzdem den Titel-Dialog (`LaufwerkKachel.tsx:157-166`), und nach der
|
||||||
|
Auswahl legt die Pipeline ein 1:1-Abbild an statt der gewählten Titel
|
||||||
|
(`pipeline.ts:236-241`).
|
||||||
|
*Erkennen:* `Disc eingelegt in X: unknown, … Bytes`, Kachel MEDIUM
|
||||||
|
„unbekannt", Statuszeile „Sichere 1:1-Abbild …".
|
||||||
|
|
||||||
|
### Kandidat 5 — Erkennung bleibt bei 60 %, die Automatik wartet (kein Fehler, aber „tut nichts")
|
||||||
|
|
||||||
|
Alle drei Discs tragen vermutlich englische Labels (`THE_PATRIOT`,
|
||||||
|
`DEJA_VU`, `TRAINING_DAY`). TMDb antwortet auf Deutsch („Der Patriot",
|
||||||
|
„Déjà Vu – Wettlauf gegen die Zeit"); der wörtliche Vergleich
|
||||||
|
`gleichBedeutend` (`kern/metadaten/zuordnung.ts:184-195`) vergleicht nur
|
||||||
|
den deutschen `title`, nie `original_title`, und kennt keine Akzente
|
||||||
|
(Sonde 1: „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot"). Ergebnis:
|
||||||
|
Vorschlag mit 60 % statt 95 % — die Automatik startet nicht
|
||||||
|
(`automatik.ts:79-82`), im Kasten steht „Automatik wartet: Die Erkennung
|
||||||
|
ist nicht sicher genug". Wer auf Zero-Click wartet, sieht: nichts.
|
||||||
|
*Erkennen:* Kachel „Vorschlag" (gelb) statt „sehr wahrscheinlich"; die
|
||||||
|
Automatik-Zeile.
|
||||||
|
|
||||||
|
### Was ich vom anderen PC brauche, um es festzunageln
|
||||||
|
|
||||||
|
1. Die Protokoll-Zeilen des Tages (siehe oben) — am besten die ganze Datei.
|
||||||
|
2. Einstellungen → MakeMKV: der Text der Schlüssel-Karte, und MakeMKV-Version.
|
||||||
|
3. Ob die drei Discs DVD, Blu-ray oder 4K sind.
|
||||||
|
4. Was genau „nicht bearbeiten" war: kein Start, roter Text, ISO, oder ewig „liest die Titel".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fehler — nach Schwere, mit Beleg
|
||||||
|
|
||||||
|
Schwere: **HOCH** = Datenverlust, Ausfall oder stilles Scheitern ·
|
||||||
|
**MITTEL** = falsches Verhalten, das der Nutzer bemerkt · **NIEDRIG** =
|
||||||
|
Schönheit, Bedienung, Nebensache. Jede Nummer verweist auf Datei:Zeile im
|
||||||
|
Stand `473eea7`.
|
||||||
|
|
||||||
|
### HOCH
|
||||||
|
|
||||||
|
**F1 · Beta-Key mit `@` wird abgeschnitten → MakeMKV unlizenziert**
|
||||||
|
`kern/werkzeuge/schluessel.ts:19` (`KEY_MUSTER`), `:24-27`, `:212-219`.
|
||||||
|
Beleg: Sonde 9 an der heute geladenen Forum-Seite: Seite 68 Zeichen mit
|
||||||
|
`@`, Rippy zieht 62. Wirkung: siehe Kandidat 1 — auf jedem PC ohne
|
||||||
|
Hand-Key laufen Blu-ray-Rips nach dem Testzeitraum nicht mehr, und Rippy
|
||||||
|
zeigt dazu GELB „BETA-MODUS — RIPS LAUFEN" (`Karten.tsx:409-413`,
|
||||||
|
`App.tsx:115`). Dazu: `extractGueltigBis` (`:32-37`) erkennt „end of
|
||||||
|
September 2026" nicht → kein Ablaufdatum im Fenster (gemessen: leer).
|
||||||
|
Fix: Muster auf alles bis Leerzeichen/`<` (z. B. `/T-[^\s<"']{50,}/`),
|
||||||
|
Ablehnung ROT mit Klartext „Blu-ray-Rips laufen nicht", und ein Test mit
|
||||||
|
dem echten September-Schlüssel.
|
||||||
|
|
||||||
|
**F2 · Rip-Start ohne erreichbare Ablage/Arbeitsordner scheitert STILL**
|
||||||
|
`kern/ablauf/pipeline.ts:258` (`mkdirSync` ohne try), ebenso `:646`
|
||||||
|
(Audio-CD) und `:721` (ISO); Aufrufer `kern/index.ts:198` und `:236`.
|
||||||
|
Beleg: Sonde 10 + Electron-Messung (utilityProcess lebt weiter, Fehler
|
||||||
|
unsichtbar). Verstößt gegen R4. Fix: `starten()` in try/catch, Fehler als
|
||||||
|
`rip-status fehler` melden UND ins Protokoll; zusätzlich vor dem Start
|
||||||
|
eine Schreibprobe am Roh-Ordner, damit die Kachel sagt „Ablage nicht
|
||||||
|
erreichbar" statt zu schweigen; im Kern ein `process.on('unhandledRejection')`,
|
||||||
|
das ins Protokoll schreibt.
|
||||||
|
|
||||||
|
**F3 · Serien: zweite Disc derselben Staffel — Folgen werden nicht benannt, Überschreiben möglich**
|
||||||
|
`kern/ablage/struktur.ts:224-246`: `episodenUmbenennen` zählt ALLE `.mkv`
|
||||||
|
im Season-Ordner; liegen dort schon `S01E01`/`S01E02` der ersten Disc,
|
||||||
|
heißt es „Umbenennen übersprungen: 4 Dateien, 2 Zuordnungen" (Sonde 2,
|
||||||
|
real ausgeführt). Der Serien-Merker (5.5.0) und „ab Folge" laufen damit ab
|
||||||
|
Disc 2 ins Leere. Schlimmer: Folgen behalten bis dahin MakeMKVs Namen
|
||||||
|
(`ablegen.ts:198-209`, `dateinamen.ts:121`), und `title_t00.mkv` der
|
||||||
|
zweiten Disc landet im SELBEN Ordner (`ablegen.ts:152-156`) — HandBrake
|
||||||
|
bzw. `renameSync` (`ablegen.ts:95-104`) überschreiben eine gleichnamige
|
||||||
|
Datei der ersten Disc ohne Warnung, wenn deren Umbenennung ausgefallen
|
||||||
|
war (kein TMDb). Der bestehende Test `test/ablage.test.ts:106-116`
|
||||||
|
schreibt das Verhalten sogar fest. Fix: nur die NEUEN Dateien dieser
|
||||||
|
Disc umbenennen (Liste übergeben statt Ordner lesen); Zielnamen vor dem
|
||||||
|
Encode auf Kollision prüfen und nie überschreiben.
|
||||||
|
|
||||||
|
**F4 · Kern-Neustart: das Fenster bleibt taub**
|
||||||
|
`haupt/kernstart.ts:59-74` startet den Kern bis zu 3× je Minute neu, aber
|
||||||
|
`fensterVerbinden` läuft nur bei `did-finish-load` (`haupt/index.ts:245-253`);
|
||||||
|
das Fenster hält den toten Port (`kernverbindung.ts:268-282`), `anKern`
|
||||||
|
schickt ins Leere, und `App.tsx:92` zeigt keinen Fehler, weil
|
||||||
|
`verbindung.kern` noch die alte Bereit-Meldung ist. Wirkung: Laufwerke
|
||||||
|
frieren ein, Knöpfe tun nichts — bis Rippy komplett neu gestartet wird.
|
||||||
|
Das KONZEPT verspricht „Der Kern ist abgestürzt, ich starte ihn neu".
|
||||||
|
Fix: im `spawn`-Ereignis des neuen Kerns `fensterVerbinden` erneut
|
||||||
|
aufrufen und eine Zeile im Fenster zeigen.
|
||||||
|
|
||||||
|
**F31 · Nachtrag (12.09. abends, beim Bau von 5.7.0 gemessen): jede
|
||||||
|
Tonspur wurde seit 5.0 in AAC umgerechnet — nicht verlustfrei durchgereicht**
|
||||||
|
`kern/komprimieren/handbrake.ts` gibt `--aencoder copy --audio-fallback
|
||||||
|
av_aac` mit, aber KEIN `--audio-copy-mask`. HandBrakeCLI 1.11.2 verengt
|
||||||
|
die Kopiermaske dann auf „copy:aac" — obwohl die Presets („H.265 MKV
|
||||||
|
1080p30", „H.265 VCN 2160p 4K" …) alle Codecs erlauben. Beleg: Encode-
|
||||||
|
Protokoll `Auto Passthru: allowed codecs are AAC … passthru not possible
|
||||||
|
for track 1, using fallback`; Ausgabe per ffprobe `aac`. Mit
|
||||||
|
ausdrücklicher Maske (`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac`) bleibt
|
||||||
|
AC3 AC3 und die Passthru-Zeile nennt alle acht Codecs (Messung in
|
||||||
|
`test/messung.spuren.test.ts`, `RIPPY_MESSUNG=1`). Wirkung: Jede seit
|
||||||
|
5.0 komprimierte Datei trägt AAC statt DTS-HD MA/TrueHD/AC3 — wo die
|
||||||
|
Roh-Dateien nach der Kompression gelöscht wurden, ist der Original-Ton
|
||||||
|
weg. Fix in 5.7.0: Maske auf beiden Wegen (Sprachlisten und Spuren
|
||||||
|
einzeln). Filme, bei denen der HD-Ton wichtig ist, noch einmal rippen.
|
||||||
|
|
||||||
|
### MITTEL
|
||||||
|
|
||||||
|
**F5 · Schlüssel-Prüfung urteilt ohne Grundlage**
|
||||||
|
`schluessel.ts:108-131`: `info disc:9999` liefert ohne angeschlossenes
|
||||||
|
Laufwerk NUR 5042/5010 (heute gemessen) — keine Lizenzzeile, also
|
||||||
|
`ok: true` und „angenommen", egal was im Register steht. `:179`:
|
||||||
|
das Ergebnis von `keyAblegen` (reg add) wird ignoriert — „hinterlegt"
|
||||||
|
steht auch dann im Protokoll, wenn nichts geschrieben wurde. `:194-237`:
|
||||||
|
Ablehnung liefert `weiterBetrieb: true` (gelb) statt rot.
|
||||||
|
|
||||||
|
**F6 · Jeder Auswurf legt eine weitere Takt-Kette der Disc-Wache an**
|
||||||
|
`kern/laufwerk/wache.ts:285-303`: `runde()` plant IMMER einen neuen
|
||||||
|
Timer, ohne den laufenden zu löschen; `laufwerke.ts:175` ruft `runde()`
|
||||||
|
nach jedem Auswurf. Beleg: Sonde 3 — drei Ketten, neunmal statt fünfmal
|
||||||
|
gelesen. Wirkung: nach 20 Discs fragt Rippy die Laufwerke alle 150 ms
|
||||||
|
statt alle 3 s (CreateFileW + IOCTL je Runde). Fix: vorhandenen Timer
|
||||||
|
in `runde()` erst löschen.
|
||||||
|
|
||||||
|
**F7 · Während der Kompression wird jede Sekunde die ganze Welt neu vermessen**
|
||||||
|
Kette: HandBrake-Fortschritt (mit ETA ≈ 1×/s, `handbrake.ts:152-158`) →
|
||||||
|
`warteschlange.ts:141-146` → `vorgaenge.ts:118-124` `senden()` →
|
||||||
|
`stand()` (`:217-260`) läuft mit `ordnerGroesse` rekursiv durch JEDEN
|
||||||
|
Roh-Ordner plus `fremdeOrdner`, danach `speicher.senden()` mit zwei
|
||||||
|
`statfsSync` (`speicher.ts:237-247`) — alles synchron im Kern; dann geht
|
||||||
|
die volle Vorgangsliste an Haupt und Fenster, das komplett neu zeichnet
|
||||||
|
(`kernverbindung.ts:123-125`). Auf einer NAS als Arbeitsordner ist das
|
||||||
|
ein Netz-Ordnerlauf pro Sekunde. Fix: Fortschritt als kleine Nachricht
|
||||||
|
getrennt vom Vorgangs-Stand; Roh-Größen nur bei Phasenwechsel oder
|
||||||
|
gedrosselt (z. B. alle 30 s) messen.
|
||||||
|
|
||||||
|
**F8 · Das Rettungs-Abbild wird gelöscht, wenn der Rip daraus leer ausgeht**
|
||||||
|
`pipeline.ts:279-300`: `beenden()` entfernt den ganzen Roh-Ordner, wenn
|
||||||
|
keine MKV entstand — inklusive `rettung.iso`, für das die Disc stundenlang
|
||||||
|
Sektor für Sektor gelesen wurde (`halbeDateienEntfernen` schont es,
|
||||||
|
`beenden` nicht). Fix: `rettung.iso` + Bericht vor dem `rmSync` verschonen.
|
||||||
|
|
||||||
|
**F9 · Halbe HandBrake-Ausgabe bleibt nach Abbruch/Fehler liegen**
|
||||||
|
`handbrake.ts:282-285,300-308`, `ablegen.ts:279-286`: nach `kill()` oder
|
||||||
|
Fehler wird die angefangene Datei nicht entfernt. Ohne Arbeitsordner
|
||||||
|
liegt sie direkt in `Filme\<Titel>\` — Jellyfin zeigt einen kaputten
|
||||||
|
Film. Fix: nach `.part`/Zwischenname encodieren, erst am Ende umbenennen;
|
||||||
|
bei Abbruch löschen.
|
||||||
|
|
||||||
|
**F10 · Typ „unbekannt" heißt stumm ISO, obwohl der Nutzer Titel gewählt hat**
|
||||||
|
`pipeline.ts:236-241` gegen `LaufwerkKachel.tsx:157-166`/`Uebersicht.tsx:114-120`
|
||||||
|
(siehe Kandidat 4). Dazu `disc.ts:49`: UHD wird allein an ≥ 55 GiB
|
||||||
|
erkannt — eine 4K-Disc mit 48 GB Daten wird „bluray" (Sonde 4) und
|
||||||
|
bekommt das Blu-ray-Preset. Fix: liegt ein Titel-Auftrag vor, immer
|
||||||
|
MakeMKV; UHD-Merkmal am Gerät nachmessen (Regel D) statt Größe.
|
||||||
|
|
||||||
|
**F11 · Laufwerks-Gesundheit beschuldigt das falsche Gerät**
|
||||||
|
`kern/laufwerk/ereignisse.ts:41-44`: der XPath nimmt Provider `cdrom` UND
|
||||||
|
`disk` für ALLE Geräte; `:67-76` filtert nicht nach `\Device\CdRomN`.
|
||||||
|
Eine hustende Festplatte oder ein zweites Laufwerk erzeugt „VERDACHT:
|
||||||
|
DIE HARDWARE" für die Kachel des Blu-ray-Laufwerks. Fix: Gerät des
|
||||||
|
Laufwerksbuchstabens ermitteln und nur dessen Ereignisse zählen.
|
||||||
|
|
||||||
|
**F12 · Zeiten stehen in UTC, nicht in deiner Uhrzeit**
|
||||||
|
`bausteine.tsx:35-38` (`datumKurz`), `Karten.tsx:269,276,418,574`,
|
||||||
|
`VerlaufKarte.tsx:295`: ISO-Strings werden abgeschnitten — 21:30 Uhr
|
||||||
|
erscheint als 19:30 (Sonde 5). Fix: lokal formatieren.
|
||||||
|
|
||||||
|
**F13 · Eigener Werkzeug-Pfad eingetragen → Anzeige bleibt „NICHT GEFUNDEN"**
|
||||||
|
`kern/index.ts:245-254`: nach `werkzeug.*` wird `werkzeuge.senden()` nicht
|
||||||
|
angestoßen (auch `makemkvAppKey` löst keinen Schlüssel-Lauf aus). In der
|
||||||
|
Erst-Einrichtung (`ErstEinrichtung.tsx:112-121`) bleibt die rote Zeile
|
||||||
|
stehen, obwohl der Pfad stimmt. Fix: nach diesen Schlüsseln messen bzw.
|
||||||
|
prüfen.
|
||||||
|
|
||||||
|
**F14 · Erkennung kennt weder Originaltitel noch Akzente**
|
||||||
|
`zuordnung.ts:184-195,302-309`, `tmdb.ts:24-34` (kein `original_title`).
|
||||||
|
Siehe Kandidat 5. Fix: `original_title`/`original_name` mitvergleichen,
|
||||||
|
Akzente falten (NFD + Kombinationszeichen entfernen).
|
||||||
|
|
||||||
|
**F15 · Tray „Beenden" tötet einen laufenden Rip ohne Rückfrage**
|
||||||
|
`haupt/index.ts:278-281,383-387`, `tray.ts:291-297`: `app.quit()` → Kern
|
||||||
|
`kill()` → die Leine reißt makemkvcon/HandBrake mit — 40 Minuten Rip weg.
|
||||||
|
Fix: bei aktiven Vorgängen nachfragen.
|
||||||
|
|
||||||
|
**F16 · MakeMKV-Installer aus dem Internet Archive mit Adminrechten**
|
||||||
|
`beschaffen.ts:143-155,193-232`, `haupt/index.ts:206-216`: fällt der
|
||||||
|
Hersteller aus, wird eine EXE vom Archiv geladen und per UAC gestartet;
|
||||||
|
geprüft wird nur die Versions-Ressource (fälschbar). Für ein Programm im
|
||||||
|
Bekanntenkreis ein Risiko. Fix: SHA-256 einer bekannten Fassung
|
||||||
|
mitführen oder Archiv-Quelle nur nach Bestätigung.
|
||||||
|
|
||||||
|
### NIEDRIG
|
||||||
|
|
||||||
|
**F17** „Roh-Dateien löschen" und „Ordner löschen" ohne Rückfrage — ein Klick, 30 GB weg (`Karten.tsx:315-319,351-353`; `vorgaenge.ts:305-349`).
|
||||||
|
**F18** Eingabefelder speichern nur beim Verlassen; Enter und Bereichswechsel verlieren Tipparbeit (`bausteine.tsx:356-389`).
|
||||||
|
**F19** Dialoge ohne Escape, Fokus-Fang, `role="dialog"` (`bausteine.tsx:453-473`).
|
||||||
|
**F20** Toast „Disc erkannt" bis zu dreimal je Disc — Erkennung, Nachprüfung, „Ändern" (`haupt/index.ts:126-134`).
|
||||||
|
**F21** Poster-Fehler wird verschluckt: Rückgabe von `posterSpeichern` ignoriert (`ablegen.ts:385-387`, R4).
|
||||||
|
**F22** `finden()` ruft bei fehlendem MakeMKV dreimal `reg query … /s` synchron (bis 45 s) — bei jedem Aufruf, auch alle 15 s im Update-Lauf (`katalog.ts:168-203`).
|
||||||
|
**F23** NFO ohne `<uniqueid type="tmdb">` — Jellyfin muss über Titel/Jahr raten (`nfo.ts:187-209`).
|
||||||
|
**F24** Doppelter Zielordner bekommt den Zusatz „[bluray-2]" (`struktur.ts:158-170`, `vorgangsName.slice(0,8)`).
|
||||||
|
**F25** ASCII-Umlaute in Knopftexten: „auswaehlen", „Titel ueberspringen", „naechsten" (`LaufwerkKachel.tsx:161,413,415`).
|
||||||
|
**F26** Phase `rettet` fehlt in der Haupt-Liste aktiver Vorgänge (`haupt/index.ts:184`) — Update-Sperre und Taskleiste greifen im Rettungs-Abbild nicht.
|
||||||
|
**F27** „Neu komprimieren" wird auch für verschobene (unkomprimierte) Vorgänge angeboten (`vorgaenge.ts:245`) — Klick endet in „Roh-Dateien sind nicht mehr da".
|
||||||
|
**F28** Automatik schweigt bei Typ `unknown`/`cd` (`automatik.ts:77`) — kein Grund im Kasten.
|
||||||
|
**F29** Ton: `--first-audio` nimmt je Sprache genau EINE Spur (`handbrake.ts:56`) — Kommentar- oder Stereo-Spuren derselben Sprache fallen weg. Bewusst? Dann ins Fenster schreiben.
|
||||||
|
**F30** Audio-CD: `audioSpurLesen` blockiert den Kern synchron je Spur (`disc.ts:200-220`) — Abbrechen wirkt erst zwischen zwei Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Auffälligkeiten (keine Fehler, aber wissenswert)
|
||||||
|
|
||||||
|
**A1 · Der „Datenbank-Befund vom 01.09." ist gelöst — ein Messartefakt.**
|
||||||
|
Das Terminal der Claude-Desktop-App läuft in einem MSIX-Paket; alles,
|
||||||
|
was daraus startet, sieht `%APPDATA%`, `%LOCALAPPDATA%` und `HKCU`
|
||||||
|
virtualisiert (Schattenkopie unter
|
||||||
|
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\…`). Deshalb
|
||||||
|
zeigte `%APPDATA%\Rippy\rippy.db` hier Schema 3 vom 01.09. — die ECHTE
|
||||||
|
Datei (über `\\localhost\C$\…` gelesen) ist Schema 4, 36 864 Bytes, Stand
|
||||||
|
04.09. 22:02, mit allen Einstellungen. Rippy ist unschuldig. Folge: die
|
||||||
|
Lizenzzeilen 5054/5051 in `beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt`
|
||||||
|
kamen aus dem Paket-Register ohne Schlüssel — kein Beleg für deinen
|
||||||
|
echten Zustand. (Als Gedächtnis-Notiz für künftige Sitzungen hinterlegt.)
|
||||||
|
|
||||||
|
**A2 · Rippy schreibt in MakeMKVs eigene Einstellungen** (`HKCU\Software\MakeMKV`:
|
||||||
|
`app_Key`, `app_DefaultSelectionString`, `app_UpdateEnable`). Damit
|
||||||
|
nimmt auch das MakeMKV-GUI seitdem ALLE Spuren mit (größere Dateien beim
|
||||||
|
Handbetrieb) — beabsichtigt, aber nirgends im Fenster gesagt.
|
||||||
|
|
||||||
|
**A3 · Das Rettungs-Abbild ist bei AACS-Discs (Blu-ray/4K) ungeprüft.**
|
||||||
|
Ein Sektor-Abbild bleibt verschlüsselt; ob makemkvcon es als `iso:` ohne
|
||||||
|
Laufwerk öffnet, hängt an MakeMKVs Schlüsseldaten. Bei DVDs (CSS) sollte
|
||||||
|
es gehen. Am Gerät messen, bevor es jemand im Ernstfall braucht.
|
||||||
|
|
||||||
|
**A4 · 4K wird standardmäßig komprimiert** — mit dem 8-Bit-Preset
|
||||||
|
„H.265 … 2160p 4K" bzw. auf CPU mit „H.265 MKV 1080p30" (`presets.ts:23-49`,
|
||||||
|
`preset-empfehlung.ts:321-335`). HDR-Metadaten und 10 Bit gehen dabei
|
||||||
|
verloren, der Encode dauert Stunden. Das Docker-Rippy hatte „4K
|
||||||
|
verlustfrei" als Regel. Entscheid nötig: Standard „keine" für UHD oder
|
||||||
|
ein 10-Bit-Preset.
|
||||||
|
|
||||||
|
**A5 · Toter Schlüssel `transcodePreset`** in `presets.ts:47-48` — seit
|
||||||
|
5.5.0 nicht mehr in `PIPELINE_EINSTELLUNGEN`, kann nie greifen.
|
||||||
|
|
||||||
|
**A6 · Titel-Lesen: 300 s fest verdrahtet**, kein Regler, Meldung nennt
|
||||||
|
die Disc „beschädigt" (`erkennung.ts:354-356`) — siehe Kandidat 2.
|
||||||
|
|
||||||
|
**A7 · Der Klick-Beweis und die 450 Tests sind gut, aber blind für genau
|
||||||
|
die Funde oben:** kein Test mit Bestandsdateien im Season-Ordner, keiner
|
||||||
|
für mehrfaches `runde()`, keiner mit einem Key mit `@`, keiner für den
|
||||||
|
Rip-Start ohne Ablage, keiner für den Kern-Neustart.
|
||||||
|
|
||||||
|
**A8 · Sicherheit/Härtung ist sonst gut:** contextIsolation + sandbox im
|
||||||
|
Fenster, CSP im Bau, keine Shell-Aufrufe mit Nutzerdaten, PowerShell-
|
||||||
|
Pfad wird korrekt gequotet, Update nur über HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vorschläge zur Verbesserung — in dieser Reihenfolge
|
||||||
|
|
||||||
|
1. **F1 sofort** (eine Zeile plus Test, plus rote Ampel) — vor dem 30.09.
|
||||||
|
ist das der einzige Fund, der beide PCs betrifft.
|
||||||
|
2. **F2 + F4 + F6** — die drei „stillen" Fehler; jeder ist klein, alle
|
||||||
|
drei kosten Vertrauen („tut nichts, sagt nichts").
|
||||||
|
3. **F3** — Serien mit mehr als einer Disc gehen sonst kaputt; Test mit
|
||||||
|
vorhandenen Dateien im Ordner dazu.
|
||||||
|
4. **F8 + F9** — Aufräumen, das nicht zu viel und nicht zu wenig löscht.
|
||||||
|
5. **Kandidat 2** — Titel-Lesen-Grenze auf 10–15 min, Meldung ehrlich
|
||||||
|
(„die Disc braucht länger — läuft weiter / abbrechen"), Grenze in den
|
||||||
|
Einstellungen.
|
||||||
|
6. **F7** — Fortschritt von Vorgangs-Stand trennen; spürbar auf NAS.
|
||||||
|
7. **F14** — Originaltitel + Akzente: schlägt sich direkt in der
|
||||||
|
Trefferquote der Automatik nieder.
|
||||||
|
8. **F10/F11/F12/F13** — sichtbare Kleinigkeiten, je ein halber Nachmittag.
|
||||||
|
9. **F15/F17** — zwei Rückfragen, die Daten retten.
|
||||||
|
10. **A4** — dein Entscheid zu 4K.
|
||||||
|
11. **F31** (Nachtrag) — ist in 5.7.0 behoben; Filme mit wichtigem
|
||||||
|
HD-Ton, deren Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was gemessen wurde (nachvollziehbar)
|
||||||
|
|
||||||
|
* Typprüfung und Tests: `npm run typecheck`, `npx vitest run` — grün
|
||||||
|
(450 bestanden, 12 übersprungen).
|
||||||
|
* Sonden gegen den echten Code (Vitest, außerhalb des Repos):
|
||||||
|
S1 Akzente/Originaltitel · S2 Episoden-Umbenennung mit Bestand ·
|
||||||
|
S3 Wache-Takt-Ketten (9 statt ~5 Runden) · S4 Typ-Zuordnung ·
|
||||||
|
S5 UTC-Anzeige · S6 Labels der drei Filme · S7 99-Titel-Muster ·
|
||||||
|
S8 `halbeDateienEntfernen` · S9 Beta-Key von der echten Forum-Seite ·
|
||||||
|
S10 Rip-Start mit unerreichbarer Ablage.
|
||||||
|
* Electron 44: utilityProcess überlebt unbehandelte Promise-Ablehnung
|
||||||
|
(zwei Läufe, mit und ohne Horcher).
|
||||||
|
* Forum-Seite t=1053 heute geladen: Key mit `@`, „valid until end of
|
||||||
|
September 2026".
|
||||||
|
* Echte `rippy.db` dieses PCs über `\\localhost\C$`: Schema 4,
|
||||||
|
`makemkvAppKey` 68 Zeichen mit `@`, `ablage E:\Rippy`, `automatik aus`,
|
||||||
|
Jellyfin, TMDb- und OMDb-Schlüssel gesetzt, keine Vorgänge (Verlauf
|
||||||
|
leer), Bibliothek leer.
|
||||||
|
* Protokolle dieses PCs (02.–05.09.): Rippy 5.5.0 → 5.6.0 → 5.6.1,
|
||||||
|
Kizuna erkannt (95 %), Titel-Lauf 22–92 s, Laufwerk G am 02.09. mit
|
||||||
|
„2 Controllerfehler, 112 fehlerhafte Blöcke" (siehe F11 — ob das dein
|
||||||
|
BU40N war oder eine Platte, weiß der Bericht nicht).
|
||||||
|
* `makemkvcon64 -r --noscan info disc:9999` heute: nur 5042/5010 — ohne
|
||||||
|
Laufwerk keine Lizenzauskunft (F5).
|
||||||
|
|
||||||
|
Nichts davon hat etwas am Repo, an deinen Einstellungen oder am Register
|
||||||
|
verändert; die Sonden liegen im Sitzungs-Scratchpad.
|
||||||
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 445 KiB |
@@ -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,114 @@
|
|||||||
|
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
|
||||||
|
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
|
||||||
|
//
|
||||||
|
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
|
||||||
|
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
|
||||||
|
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
|
||||||
|
// electron-updater ihn per spawn(…, { detached: true }) startet und
|
||||||
|
// `detached` unter Windows KEIN Breakaway setzt.
|
||||||
|
//
|
||||||
|
// Aufbau (drei Läufe, der äußere ruft die inneren):
|
||||||
|
// node beweise/leine-breakaway.js
|
||||||
|
// -> startet zweimal sich selbst als „innen":
|
||||||
|
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
|
||||||
|
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
|
||||||
|
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
|
||||||
|
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
|
||||||
|
// welcher Enkel noch lebt.
|
||||||
|
//
|
||||||
|
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
|
||||||
|
//
|
||||||
|
// Ausführen: node beweise/leine-breakaway.js
|
||||||
|
const { execFileSync, spawnSync } = require('node:child_process')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
|
||||||
|
|
||||||
|
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||||
|
const KILL_ON_JOB_CLOSE = 0x2000
|
||||||
|
const BREAKAWAY_OK = 0x0800
|
||||||
|
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||||
|
const CREATE_NO_WINDOW = 0x08000000
|
||||||
|
const STRUKTUR_GROESSE = 144
|
||||||
|
const LIMIT_FLAGS_OFFSET = 16
|
||||||
|
const STARTUPINFO_GROESSE = 104
|
||||||
|
const PROCESS_INFORMATION_GROESSE = 24
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll')
|
||||||
|
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
|
||||||
|
const SetInformationJobObject = kernel32.func(
|
||||||
|
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
|
||||||
|
)
|
||||||
|
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
const CreateProcessW = kernel32.func(
|
||||||
|
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
|
||||||
|
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
|
||||||
|
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
|
||||||
|
|
||||||
|
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
|
||||||
|
const job = CreateJobObjectW(null, null)
|
||||||
|
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||||
|
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
|
||||||
|
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
|
||||||
|
AssignProcessToJobObject(job, GetCurrentProcess())
|
||||||
|
|
||||||
|
const si = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||||
|
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||||
|
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||||
|
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
|
||||||
|
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
|
||||||
|
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
|
||||||
|
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
|
||||||
|
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
|
||||||
|
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
|
||||||
|
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
|
||||||
|
|
||||||
|
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
|
||||||
|
if (!ok) {
|
||||||
|
console.log(`PID=0 FEHLER=${GetLastError()}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
|
||||||
|
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lebt(pid) {
|
||||||
|
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
|
||||||
|
return (ergebnis.stdout || '').includes(String(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
function aussen() {
|
||||||
|
const faelle = [
|
||||||
|
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
|
||||||
|
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
|
||||||
|
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
|
||||||
|
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
|
||||||
|
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
|
||||||
|
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
|
||||||
|
]
|
||||||
|
for (const [name, modus, erwartetLebend] of faelle) {
|
||||||
|
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
|
||||||
|
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||||
|
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||||
|
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
|
||||||
|
// zugeschnappt, falls sie zuschnappt.
|
||||||
|
const nochDa = pid > 0 ? lebt(pid) : false
|
||||||
|
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
|
||||||
|
console.log(
|
||||||
|
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
|
||||||
|
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
|
||||||
|
)
|
||||||
|
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modus = process.argv[2] ?? ''
|
||||||
|
if (modus === 'alt') innen(false, false)
|
||||||
|
else if (modus === 'erlaubt-normal') innen(true, false)
|
||||||
|
else if (modus === 'erlaubt-aus') innen(true, true)
|
||||||
|
else aussen()
|
||||||
@@ -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,375 @@
|
|||||||
|
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||||
|
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
|
||||||
|
DRV:1,256,999,0,"","",""
|
||||||
|
DRV:2,256,999,0,"","",""
|
||||||
|
DRV:3,256,999,0,"","",""
|
||||||
|
DRV:4,256,999,0,"","",""
|
||||||
|
DRV:5,256,999,0,"","",""
|
||||||
|
DRV:6,256,999,0,"","",""
|
||||||
|
DRV:7,256,999,0,"","",""
|
||||||
|
DRV:8,256,999,0,"","",""
|
||||||
|
DRV:9,256,999,0,"","",""
|
||||||
|
DRV:10,256,999,0,"","",""
|
||||||
|
DRV:11,256,999,0,"","",""
|
||||||
|
DRV:12,256,999,0,"","",""
|
||||||
|
DRV:13,256,999,0,"","",""
|
||||||
|
DRV:14,256,999,0,"","",""
|
||||||
|
DRV:15,256,999,0,"","",""
|
||||||
|
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
|
||||||
|
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
|
||||||
|
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
|
||||||
|
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
|
||||||
|
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
|
||||||
|
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
|
||||||
|
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
|
||||||
|
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
|
||||||
|
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
|
||||||
|
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
|
||||||
|
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
|
||||||
|
TCOUNT:2
|
||||||
|
CINFO:1,6209,"Blu-ray-Disk"
|
||||||
|
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:28,0,"eng"
|
||||||
|
CINFO:29,0,"English"
|
||||||
|
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
|
||||||
|
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
|
||||||
|
CINFO:33,0,"0"
|
||||||
|
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:0,8,0,"6"
|
||||||
|
TINFO:0,9,0,"0:08:51"
|
||||||
|
TINFO:0,10,0,"1.1 GB"
|
||||||
|
TINFO:0,11,0,"1219086336"
|
||||||
|
TINFO:0,16,0,"00011.mpls"
|
||||||
|
TINFO:0,25,0,"6"
|
||||||
|
TINFO:0,26,0,"9,10,11,12,13,14"
|
||||||
|
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
|
||||||
|
TINFO:0,28,0,"eng"
|
||||||
|
TINFO:0,29,0,"English"
|
||||||
|
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
|
||||||
|
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:0,33,0,"0"
|
||||||
|
SINFO:0,0,1,6201,"Video"
|
||||||
|
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:0,0,6,0,"Mpeg4"
|
||||||
|
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,19,0,"1920x1080"
|
||||||
|
SINFO:0,0,20,0,"16:9"
|
||||||
|
SINFO:0,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:0,0,22,0,"0"
|
||||||
|
SINFO:0,0,28,0,"eng"
|
||||||
|
SINFO:0,0,29,0,"English"
|
||||||
|
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,0,33,0,"0"
|
||||||
|
SINFO:0,0,38,0,""
|
||||||
|
SINFO:0,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,1,1,6202,"Audio"
|
||||||
|
SINFO:0,1,2,5091,"Stereo"
|
||||||
|
SINFO:0,1,3,0,"deu"
|
||||||
|
SINFO:0,1,4,0,"German"
|
||||||
|
SINFO:0,1,5,0,"A_DTS"
|
||||||
|
SINFO:0,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,1,14,0,"2"
|
||||||
|
SINFO:0,1,17,0,"48000"
|
||||||
|
SINFO:0,1,18,0,"16"
|
||||||
|
SINFO:0,1,22,0,"1024"
|
||||||
|
SINFO:0,1,28,0,"eng"
|
||||||
|
SINFO:0,1,29,0,"English"
|
||||||
|
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,1,33,0,"100"
|
||||||
|
SINFO:0,1,38,0,""
|
||||||
|
SINFO:0,1,40,0,"stereo"
|
||||||
|
SINFO:0,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,2,1,6202,"Audio"
|
||||||
|
SINFO:0,2,2,5091,"Stereo"
|
||||||
|
SINFO:0,2,3,0,"deu"
|
||||||
|
SINFO:0,2,4,0,"German"
|
||||||
|
SINFO:0,2,5,0,"A_DTS"
|
||||||
|
SINFO:0,2,6,0,"DTS"
|
||||||
|
SINFO:0,2,7,0,"DTS"
|
||||||
|
SINFO:0,2,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,2,14,0,"2"
|
||||||
|
SINFO:0,2,17,0,"48000"
|
||||||
|
SINFO:0,2,22,0,"2304"
|
||||||
|
SINFO:0,2,28,0,"eng"
|
||||||
|
SINFO:0,2,29,0,"English"
|
||||||
|
SINFO:0,2,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,2,33,0,"100"
|
||||||
|
SINFO:0,2,38,0,"d"
|
||||||
|
SINFO:0,2,39,0,"Default"
|
||||||
|
SINFO:0,2,40,0,"stereo"
|
||||||
|
SINFO:0,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,3,1,6202,"Audio"
|
||||||
|
SINFO:0,3,2,5091,"Stereo"
|
||||||
|
SINFO:0,3,3,0,"deu"
|
||||||
|
SINFO:0,3,4,0,"German"
|
||||||
|
SINFO:0,3,5,0,"A_DTS"
|
||||||
|
SINFO:0,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,3,14,0,"2"
|
||||||
|
SINFO:0,3,17,0,"48000"
|
||||||
|
SINFO:0,3,18,0,"16"
|
||||||
|
SINFO:0,3,22,0,"1024"
|
||||||
|
SINFO:0,3,28,0,"eng"
|
||||||
|
SINFO:0,3,29,0,"English"
|
||||||
|
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,3,33,0,"100"
|
||||||
|
SINFO:0,3,38,0,""
|
||||||
|
SINFO:0,3,40,0,"stereo"
|
||||||
|
SINFO:0,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,4,1,6202,"Audio"
|
||||||
|
SINFO:0,4,2,5091,"Stereo"
|
||||||
|
SINFO:0,4,3,0,"deu"
|
||||||
|
SINFO:0,4,4,0,"German"
|
||||||
|
SINFO:0,4,5,0,"A_DTS"
|
||||||
|
SINFO:0,4,6,0,"DTS"
|
||||||
|
SINFO:0,4,7,0,"DTS"
|
||||||
|
SINFO:0,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,4,14,0,"2"
|
||||||
|
SINFO:0,4,17,0,"48000"
|
||||||
|
SINFO:0,4,22,0,"2304"
|
||||||
|
SINFO:0,4,28,0,"eng"
|
||||||
|
SINFO:0,4,29,0,"English"
|
||||||
|
SINFO:0,4,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,4,33,0,"100"
|
||||||
|
SINFO:0,4,38,0,""
|
||||||
|
SINFO:0,4,40,0,"stereo"
|
||||||
|
SINFO:0,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,5,1,6203,"Untertitel"
|
||||||
|
SINFO:0,5,3,0,"deu"
|
||||||
|
SINFO:0,5,4,0,"German"
|
||||||
|
SINFO:0,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,5,6,0,"PGS"
|
||||||
|
SINFO:0,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,5,22,0,"0"
|
||||||
|
SINFO:0,5,28,0,"eng"
|
||||||
|
SINFO:0,5,29,0,"English"
|
||||||
|
SINFO:0,5,30,0,"PGS German"
|
||||||
|
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,5,33,0,"100"
|
||||||
|
SINFO:0,5,38,0,""
|
||||||
|
SINFO:0,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,6,1,6203,"Untertitel"
|
||||||
|
SINFO:0,6,3,0,"deu"
|
||||||
|
SINFO:0,6,4,0,"German"
|
||||||
|
SINFO:0,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,6,6,0,"PGS"
|
||||||
|
SINFO:0,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,6,22,0,"6144"
|
||||||
|
SINFO:0,6,28,0,"eng"
|
||||||
|
SINFO:0,6,29,0,"English"
|
||||||
|
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,6,33,0,"100"
|
||||||
|
SINFO:0,6,38,0,"d"
|
||||||
|
SINFO:0,6,39,0,"Default"
|
||||||
|
SINFO:0,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,7,1,6203,"Untertitel"
|
||||||
|
SINFO:0,7,3,0,"deu"
|
||||||
|
SINFO:0,7,4,0,"German"
|
||||||
|
SINFO:0,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,7,6,0,"PGS"
|
||||||
|
SINFO:0,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,7,22,0,"0"
|
||||||
|
SINFO:0,7,28,0,"eng"
|
||||||
|
SINFO:0,7,29,0,"English"
|
||||||
|
SINFO:0,7,30,0,"PGS German"
|
||||||
|
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,7,33,0,"100"
|
||||||
|
SINFO:0,7,38,0,""
|
||||||
|
SINFO:0,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,8,1,6203,"Untertitel"
|
||||||
|
SINFO:0,8,3,0,"deu"
|
||||||
|
SINFO:0,8,4,0,"German"
|
||||||
|
SINFO:0,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,8,6,0,"PGS"
|
||||||
|
SINFO:0,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,8,22,0,"6144"
|
||||||
|
SINFO:0,8,28,0,"eng"
|
||||||
|
SINFO:0,8,29,0,"English"
|
||||||
|
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,8,33,0,"100"
|
||||||
|
SINFO:0,8,38,0,""
|
||||||
|
SINFO:0,8,42,5088,"( Lossless conversion )"
|
||||||
|
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:1,8,0,"13"
|
||||||
|
TINFO:1,9,0,"1:34:45"
|
||||||
|
TINFO:1,10,0,"16.4 GB"
|
||||||
|
TINFO:1,11,0,"17668571136"
|
||||||
|
TINFO:1,16,0,"00003.mpls"
|
||||||
|
TINFO:1,25,0,"1"
|
||||||
|
TINFO:1,26,0,"7"
|
||||||
|
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
|
||||||
|
TINFO:1,28,0,"eng"
|
||||||
|
TINFO:1,29,0,"English"
|
||||||
|
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
|
||||||
|
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:1,33,0,"0"
|
||||||
|
SINFO:1,0,1,6201,"Video"
|
||||||
|
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:1,0,6,0,"Mpeg4"
|
||||||
|
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,19,0,"1920x1080"
|
||||||
|
SINFO:1,0,20,0,"16:9"
|
||||||
|
SINFO:1,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:1,0,22,0,"0"
|
||||||
|
SINFO:1,0,28,0,"eng"
|
||||||
|
SINFO:1,0,29,0,"English"
|
||||||
|
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,0,33,0,"0"
|
||||||
|
SINFO:1,0,38,0,""
|
||||||
|
SINFO:1,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,1,1,6202,"Audio"
|
||||||
|
SINFO:1,1,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,1,3,0,"deu"
|
||||||
|
SINFO:1,1,4,0,"German"
|
||||||
|
SINFO:1,1,5,0,"A_DTS"
|
||||||
|
SINFO:1,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,1,14,0,"6"
|
||||||
|
SINFO:1,1,17,0,"48000"
|
||||||
|
SINFO:1,1,18,0,"16"
|
||||||
|
SINFO:1,1,22,0,"1024"
|
||||||
|
SINFO:1,1,28,0,"eng"
|
||||||
|
SINFO:1,1,29,0,"English"
|
||||||
|
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
|
||||||
|
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,1,33,0,"100"
|
||||||
|
SINFO:1,1,38,0,""
|
||||||
|
SINFO:1,1,40,0,"5.1(side)"
|
||||||
|
SINFO:1,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,2,1,6202,"Audio"
|
||||||
|
SINFO:1,2,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,2,3,0,"deu"
|
||||||
|
SINFO:1,2,4,0,"German"
|
||||||
|
SINFO:1,2,5,0,"A_DTS"
|
||||||
|
SINFO:1,2,6,0,"DTS"
|
||||||
|
SINFO:1,2,7,0,"DTS"
|
||||||
|
SINFO:1,2,13,0,"1.5 Mb/s"
|
||||||
|
SINFO:1,2,14,0,"6"
|
||||||
|
SINFO:1,2,17,0,"48000"
|
||||||
|
SINFO:1,2,22,0,"2304"
|
||||||
|
SINFO:1,2,28,0,"eng"
|
||||||
|
SINFO:1,2,29,0,"English"
|
||||||
|
SINFO:1,2,30,0,"DTS Surround 5.1 German"
|
||||||
|
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,2,33,0,"100"
|
||||||
|
SINFO:1,2,38,0,"d"
|
||||||
|
SINFO:1,2,39,0,"Default"
|
||||||
|
SINFO:1,2,40,0,"5.1(side)"
|
||||||
|
SINFO:1,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,3,1,6202,"Audio"
|
||||||
|
SINFO:1,3,2,5091,"Stereo"
|
||||||
|
SINFO:1,3,3,0,"jpn"
|
||||||
|
SINFO:1,3,4,0,"Japanese"
|
||||||
|
SINFO:1,3,5,0,"A_DTS"
|
||||||
|
SINFO:1,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,3,14,0,"2"
|
||||||
|
SINFO:1,3,17,0,"48000"
|
||||||
|
SINFO:1,3,18,0,"16"
|
||||||
|
SINFO:1,3,22,0,"1024"
|
||||||
|
SINFO:1,3,28,0,"eng"
|
||||||
|
SINFO:1,3,29,0,"English"
|
||||||
|
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
|
||||||
|
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,3,33,0,"100"
|
||||||
|
SINFO:1,3,38,0,""
|
||||||
|
SINFO:1,3,40,0,"stereo"
|
||||||
|
SINFO:1,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,4,1,6202,"Audio"
|
||||||
|
SINFO:1,4,2,5091,"Stereo"
|
||||||
|
SINFO:1,4,3,0,"jpn"
|
||||||
|
SINFO:1,4,4,0,"Japanese"
|
||||||
|
SINFO:1,4,5,0,"A_DTS"
|
||||||
|
SINFO:1,4,6,0,"DTS"
|
||||||
|
SINFO:1,4,7,0,"DTS"
|
||||||
|
SINFO:1,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:1,4,14,0,"2"
|
||||||
|
SINFO:1,4,17,0,"48000"
|
||||||
|
SINFO:1,4,22,0,"2304"
|
||||||
|
SINFO:1,4,28,0,"eng"
|
||||||
|
SINFO:1,4,29,0,"English"
|
||||||
|
SINFO:1,4,30,0,"DTS Stereo Japanese"
|
||||||
|
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,4,33,0,"100"
|
||||||
|
SINFO:1,4,38,0,""
|
||||||
|
SINFO:1,4,40,0,"stereo"
|
||||||
|
SINFO:1,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,5,1,6203,"Untertitel"
|
||||||
|
SINFO:1,5,3,0,"deu"
|
||||||
|
SINFO:1,5,4,0,"German"
|
||||||
|
SINFO:1,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,5,6,0,"PGS"
|
||||||
|
SINFO:1,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,5,22,0,"0"
|
||||||
|
SINFO:1,5,28,0,"eng"
|
||||||
|
SINFO:1,5,29,0,"English"
|
||||||
|
SINFO:1,5,30,0,"PGS German"
|
||||||
|
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,5,33,0,"100"
|
||||||
|
SINFO:1,5,38,0,""
|
||||||
|
SINFO:1,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,6,1,6203,"Untertitel"
|
||||||
|
SINFO:1,6,3,0,"deu"
|
||||||
|
SINFO:1,6,4,0,"German"
|
||||||
|
SINFO:1,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,6,6,0,"PGS"
|
||||||
|
SINFO:1,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,6,22,0,"6144"
|
||||||
|
SINFO:1,6,28,0,"eng"
|
||||||
|
SINFO:1,6,29,0,"English"
|
||||||
|
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,6,33,0,"100"
|
||||||
|
SINFO:1,6,38,0,"d"
|
||||||
|
SINFO:1,6,39,0,"Default"
|
||||||
|
SINFO:1,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,7,1,6203,"Untertitel"
|
||||||
|
SINFO:1,7,3,0,"deu"
|
||||||
|
SINFO:1,7,4,0,"German"
|
||||||
|
SINFO:1,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,7,6,0,"PGS"
|
||||||
|
SINFO:1,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,7,22,0,"0"
|
||||||
|
SINFO:1,7,28,0,"eng"
|
||||||
|
SINFO:1,7,29,0,"English"
|
||||||
|
SINFO:1,7,30,0,"PGS German"
|
||||||
|
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,7,33,0,"100"
|
||||||
|
SINFO:1,7,38,0,""
|
||||||
|
SINFO:1,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,8,1,6203,"Untertitel"
|
||||||
|
SINFO:1,8,3,0,"deu"
|
||||||
|
SINFO:1,8,4,0,"German"
|
||||||
|
SINFO:1,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,8,6,0,"PGS"
|
||||||
|
SINFO:1,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,8,22,0,"6144"
|
||||||
|
SINFO:1,8,28,0,"eng"
|
||||||
|
SINFO:1,8,29,0,"English"
|
||||||
|
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,8,33,0,"100"
|
||||||
|
SINFO:1,8,38,0,""
|
||||||
|
SINFO:1,8,42,5088,"( Lossless conversion )"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -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"
|
REPO_URL="$1"
|
||||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||||
CLONE="${3:-$HOME/notfall-rippy}"
|
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
|
if [ ! -d "$CLONE/.git" ]; then
|
||||||
git clone --branch main "$REPO_URL" "$CLONE"
|
git clone --branch main "$REPO_URL" "$CLONE"
|
||||||
@@ -42,7 +69,44 @@ fi
|
|||||||
cd "$CLONE"
|
cd "$CLONE"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard 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 up -d --build $DIENST
|
||||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||||
REMOTE
|
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.
|
# Optionaler Remote-Transcode-Worker — für eine GPU-Maschine im Netz (Linux).
|
||||||
# (EXPERIMENTELL, 23.07.2026: VAAPI/NVENC-Presets folgen; aktuell nutzt der
|
# Für Windows gibt es den nativen Weg: RippyWorkerSetup.exe (Einstellungen → Worker).
|
||||||
# Worker dieselben HandBrake-CPU-Presets, bringt also v.a. stärkere CPUs.)
|
#
|
||||||
|
# ══ 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:
|
# Auf der GPU-Maschine:
|
||||||
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
# 1. Dieses Repo klonen (oder nur dieses File + Zugriff aufs Registry-Image)
|
||||||
# 2. Rohdaten-Freigabe der Rippy-Maschine mounten, z. B.:
|
# 2. Die Freigabe einhängen, die Rippy als Arbeitsverzeichnis UND Ablage
|
||||||
# mount -t nfs <rippy-host>:/srv/rippy /mnt/rippy
|
# benutzt (Einstellungen → Ripping). Heißt sie in Rippy „rippy", liegt
|
||||||
# (die Rippy-VM muss /srv/rippy + das temp-Volume exportieren)
|
# sie dort unter /app/media/rippy — also z. B.:
|
||||||
# 3. RIPPY_HOST unten setzen und starten:
|
# mount -t cifs //NAS/rippy /mnt/rippy-freigabe -o guest,iocharset=utf8
|
||||||
# docker compose -f deploy/remote-transcode-worker.yml up -d --build
|
# 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
|
# 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.
|
# gerippt wird weiterhin dort, wo das Laufwerk hängt.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -30,10 +49,15 @@ services:
|
|||||||
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||||
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||||
- API_URL=http://${RIPPY_HOST}:8000
|
- 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:
|
volumes:
|
||||||
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
# LINKS der Pfad auf DIESER Maschine, RECHTS der Pfad, den Rippy nennt.
|
||||||
- /mnt/rippy/media:/app/media
|
# Rechts muss stehen, was in Rippy als Speicherziel heißt: ein Ziel
|
||||||
- /mnt/rippy/temp:/app/temp
|
# 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:
|
devices:
|
||||||
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
# GPU für Hardware-Encoding (AMD/Intel: /dev/dri; NVIDIA: nvidia-runtime)
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
|
|||||||
@@ -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)
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 31 KiB |
@@ -3,7 +3,24 @@
|
|||||||
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
|
# 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
|
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
|
||||||
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
|
# 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:
|
services:
|
||||||
api:
|
api:
|
||||||
@@ -21,6 +38,7 @@ services:
|
|||||||
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
# hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
|
||||||
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
- MAKEMKV_DATA_DIR=/app/makemkv-data
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -53,8 +71,7 @@ services:
|
|||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||||
devices:
|
# devices: siehe Kopf — kommen aus docker-compose.override.yml
|
||||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -102,6 +119,7 @@ services:
|
|||||||
# Episoden-Laufzeiten der Staffel (TMDB).
|
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||||
- API_URL=http://api:8000
|
- API_URL=http://api:8000
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
- RIPPY_VERSION=${RIPPY_VERSION:-dev}
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /srv/rippy/media
|
source: /srv/rippy/media
|
||||||
@@ -116,15 +134,11 @@ services:
|
|||||||
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
||||||
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
||||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
||||||
devices:
|
# devices: siehe Kopf dieser Datei — die optischen Knoten dieses Hosts
|
||||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
# stehen in docker-compose.override.yml (deploy/geraete-override.sh).
|
||||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
# MakeMKV braucht BEIDE: /dev/srN und den passenden /dev/sgM; welche
|
||||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
# sg-Nummer dazugehoert, ist je Host anders und wird dort ueber die
|
||||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
# SCSI-Adresse abgeglichen statt geraten.
|
||||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
|
||||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
|
||||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
|
||||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -17,11 +17,25 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY docker/api/ .
|
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
|
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
|
||||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
# die Zielmaschine braucht weder git noch Docker.
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
COPY deploy/worker-windows/install.ps1 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
|
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||||
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||||
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||||
|
|||||||
@@ -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 json
|
||||||
import os
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
redis_host = os.getenv("REDIS_HOST", "redis")
|
redis_host = os.getenv("REDIS_HOST", "redis")
|
||||||
redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
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:
|
def init_cache() -> None:
|
||||||
"""Initialize cache connection."""
|
"""Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie."""
|
||||||
pass
|
global _erreichbar
|
||||||
|
try:
|
||||||
|
redis_client.ping()
|
||||||
|
_erreichbar = True
|
||||||
|
except (RedisError, OSError) as e:
|
||||||
|
_melde_einmal(e)
|
||||||
|
|
||||||
|
|
||||||
def get(key: str) -> Optional[Any]:
|
def get(key: str) -> Optional[Any]:
|
||||||
"""Get value from cache."""
|
"""Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher."""
|
||||||
value = redis_client.get(key)
|
global _erreichbar
|
||||||
|
try:
|
||||||
|
value = redis_client.get(key)
|
||||||
|
except (RedisError, OSError) as e:
|
||||||
|
_melde_einmal(e)
|
||||||
|
return None
|
||||||
|
_erreichbar = True
|
||||||
if value:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
|
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
|
||||||
"""Set value in cache."""
|
"""Wert ablegen. False heißt: ging nicht — und das ist in Ordnung."""
|
||||||
serialized = json.dumps(value)
|
global _erreichbar
|
||||||
if expire:
|
try:
|
||||||
return redis_client.setex(key, expire, serialized)
|
serialized = json.dumps(value)
|
||||||
return redis_client.set(key, serialized)
|
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:
|
def delete(key: str) -> bool:
|
||||||
"""Delete value from cache."""
|
try:
|
||||||
return redis_client.delete(key)
|
return bool(redis_client.delete(key))
|
||||||
|
except (RedisError, OSError) as e:
|
||||||
|
_melde_einmal(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def clear() -> bool:
|
def clear() -> bool:
|
||||||
"""Clear all cache."""
|
try:
|
||||||
return redis_client.flushdb()
|
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 —
|
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.
|
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
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
|
||||||
from celery.utils import worker_direct
|
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
_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):
|
def transcode_queue(node: str = None):
|
||||||
@@ -25,7 +114,9 @@ def transcode_queue(node: str = None):
|
|||||||
if not node:
|
if not node:
|
||||||
return "transcode"
|
return "transcode"
|
||||||
try:
|
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()}
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
if node in online:
|
if node in online:
|
||||||
return worker_direct(node)
|
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):
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
"""Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
|
||||||
return celery_client.send_task(
|
return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
|
||||||
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ def titel_aehnlichkeit(a: str, b: str) -> float:
|
|||||||
|
|
||||||
|
|
||||||
class JikanClient:
|
class JikanClient:
|
||||||
|
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
|
||||||
MINDEST_AEHNLICHKEIT = 0.55
|
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):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ def parse_year(year: str) -> Optional[int]:
|
|||||||
class OMDbClient:
|
class OMDbClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||||
from db import get_settings
|
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||||
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
|
# 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()
|
self.session = requests.Session()
|
||||||
|
|
||||||
def suche(self, title: str) -> list:
|
def suche(self, title: str) -> list:
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
|
|||||||
class TheTVDBClient:
|
class TheTVDBClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||||
from db import get_settings
|
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||||
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
|
# 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.base_url = THETVDB_BASE_URL
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
|
|||||||
@@ -24,8 +24,20 @@ class TMDBClient:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
||||||
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
||||||
from db import get_settings
|
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
|
||||||
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
|
#
|
||||||
|
# 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 = requests.Session()
|
||||||
self.session.headers.update({"Content-Type": "application/json"})
|
self.session.headers.update({"Content-Type": "application/json"})
|
||||||
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
# 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)}
|
||||||
@@ -50,7 +50,7 @@ def fetch_current_key(timeout: int = 15) -> str | None:
|
|||||||
def _apply_key(key: str) -> bool:
|
def _apply_key(key: str) -> bool:
|
||||||
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
|
"""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."""
|
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()
|
einstellungen = db.get_settings()
|
||||||
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
||||||
return False
|
return False
|
||||||
@@ -61,7 +61,7 @@ def _apply_key(key: str) -> bool:
|
|||||||
|
|
||||||
async def refresh_once() -> bool:
|
async def refresh_once() -> bool:
|
||||||
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
"""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)
|
key = await asyncio.to_thread(fetch_current_key)
|
||||||
if not key:
|
if not key:
|
||||||
db.add_log("warning", "makemkv-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:
|
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
||||||
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
||||||
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
|
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)
|
await asyncio.sleep(start_verzoegerung_s)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,18 +11,30 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import db
|
from rippy import store as db
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
||||||
|
|
||||||
|
|
||||||
def _mountpoint(name: str) -> str:
|
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:
|
def validiere_name(name: str) -> bool:
|
||||||
@@ -50,13 +62,69 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
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
|
return ergebnis.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return False
|
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:
|
def schreibtest(pfad: str) -> bool:
|
||||||
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
|
"""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:
|
if ergebnis.returncode != 0:
|
||||||
break # nichts (mehr) gemountet
|
break # nichts (mehr) gemountet
|
||||||
geloest += 1
|
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
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +241,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
|||||||
return freigaben
|
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 = "",
|
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
username: str = "", passwort: str = "") -> bool:
|
username: str = "", passwort: str = "") -> bool:
|
||||||
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
|
"""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.
|
Mount-Fehlern.
|
||||||
"""
|
"""
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
|
||||||
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
|
||||||
try:
|
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
|
||||||
os.makedirs(ziel, exist_ok=True)
|
if pfad_lage(ziel) == "weg":
|
||||||
except FileExistsError:
|
try:
|
||||||
pass
|
os.makedirs(ziel, exist_ok=True)
|
||||||
try:
|
except OSError:
|
||||||
if os.path.ismount(ziel):
|
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
|
||||||
return schreibtest(ziel)
|
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
|
||||||
except OSError:
|
#
|
||||||
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
# 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
|
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||||
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
# 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:
|
else:
|
||||||
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
|
||||||
|
|
||||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
|
||||||
if ergebnis.returncode != 0:
|
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
|
||||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
|
||||||
hinweis = ""
|
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
|
||||||
if "error(13)" in fehler or "Permission denied" in fehler:
|
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
|
||||||
hinweis = (
|
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
|
||||||
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
# gelegentlich schief, ohne es zu melden.
|
||||||
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
#
|
||||||
"NAS meist ablehnen."
|
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
|
||||||
)
|
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
|
||||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
|
||||||
return schreibtest(ziel)
|
# „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:
|
finally:
|
||||||
if creds_datei:
|
if creds_datei:
|
||||||
try:
|
try:
|
||||||
@@ -237,22 +424,25 @@ def _lazy_umount(ziel: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def aushaengen(name: 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)
|
ziel = _mountpoint(name)
|
||||||
try:
|
ergebnis = subprocess.run(
|
||||||
noch_mount = os.path.ismount(ziel)
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||||
except OSError:
|
)
|
||||||
noch_mount = True # stale
|
if ergebnis.returncode != 0:
|
||||||
if noch_mount:
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||||
ergebnis = subprocess.run(
|
_lazy_umount(ziel)
|
||||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
|
||||||
)
|
if pfad_lage(ziel) == "da":
|
||||||
if ergebnis.returncode != 0:
|
try:
|
||||||
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
os.rmdir(ziel)
|
||||||
_lazy_umount(ziel)
|
except OSError:
|
||||||
try:
|
pass # nicht leer oder weg — egal
|
||||||
os.rmdir(ziel)
|
|
||||||
except OSError:
|
|
||||||
pass # nicht leer oder weg — egal
|
|
||||||
|
|
||||||
|
|
||||||
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
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
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||||
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
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)
|
ziel = _mountpoint(name)
|
||||||
try:
|
_lazy_umount(ziel)
|
||||||
if os.path.ismount(ziel):
|
|
||||||
_lazy_umount(ziel)
|
|
||||||
except OSError:
|
|
||||||
_lazy_umount(ziel)
|
|
||||||
return mounten(name, typ, quelle, optionen, username, passwort)
|
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
|
||||||
@@ -6,11 +6,11 @@ lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Dict, List, Optional
|
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.tmdb import TMDBClient
|
||||||
from clients.jikan import JikanClient
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
@@ -19,6 +19,18 @@ from clients.thetvdb import TheTVDBClient
|
|||||||
from cache import get, set as cache_set
|
from cache import get, set as cache_set
|
||||||
from cache.keys import generate_prescan_key
|
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:
|
def normalize_disc_label(label: str) -> str:
|
||||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||||
@@ -75,50 +87,95 @@ def parse_udf_dstring(data: bytes) -> str:
|
|||||||
return ""
|
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]:
|
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
||||||
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
|
"""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
|
Das Volume-Label ist oft kryptisch (`BD_EVG_D2`) - der echte Titel
|
||||||
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
|
(„Evangelion 2.22") steht in den Disc-Metadaten. Unter Linux wird dafuer
|
||||||
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
|
read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
|
||||||
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
||||||
"""
|
"""
|
||||||
import re as _re
|
wurzel, aufraeumen = None, lambda: None
|
||||||
mountpoint = "/mnt/rippy-disc"
|
|
||||||
os.makedirs(mountpoint, exist_ok=True)
|
|
||||||
gemountet = False
|
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
wurzel, aufraeumen = disc_wurzel(device_path)
|
||||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
return titel_aus_bdmt(wurzel) if wurzel else None
|
||||||
capture_output=True, text=True, timeout=30,
|
|
||||||
)
|
|
||||||
if ergebnis.returncode != 0:
|
|
||||||
return None
|
|
||||||
gemountet = True
|
|
||||||
|
|
||||||
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
|
|
||||||
if not os.path.isdir(meta_dir):
|
|
||||||
return None
|
|
||||||
kandidaten = sorted(os.listdir(meta_dir))
|
|
||||||
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
|
|
||||||
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
|
|
||||||
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
|
||||||
)
|
|
||||||
if not bdmt:
|
|
||||||
return None
|
|
||||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
|
||||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
|
||||||
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
|
|
||||||
# (Namespaces variieren je Authoring-Werkzeug)
|
|
||||||
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
|
||||||
if treffer:
|
|
||||||
return treffer.group(1).strip()
|
|
||||||
return None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if gemountet:
|
try:
|
||||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
aufraeumen()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def titel_kandidaten(titel: str) -> List[str]:
|
def titel_kandidaten(titel: str) -> List[str]:
|
||||||
@@ -274,7 +331,8 @@ class PreScan:
|
|||||||
["cdparanoia", "-Q", device_path],
|
["cdparanoia", "-Q", device_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if 'track' in line.lower():
|
if 'track' in line.lower():
|
||||||
@@ -298,22 +356,37 @@ class PreScan:
|
|||||||
if label:
|
if label:
|
||||||
toc["title"] = normalize_disc_label(label)
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||||
if shutil.which("makemkvcon"):
|
#
|
||||||
result = subprocess.run(
|
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||||
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
#
|
||||||
capture_output=True,
|
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||||
text=True,
|
# lange. Das ging mal viel schneller."
|
||||||
timeout=120
|
#
|
||||||
)
|
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||||
for line in result.stdout.split('\n'):
|
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||||
if line.startswith('TINFO:'):
|
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||||
parts = line.split(',')
|
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||||
if len(parts) >= 5:
|
#
|
||||||
toc["tracks"].append({
|
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
# `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:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
@@ -390,11 +463,13 @@ class PreScan:
|
|||||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||||
kandidaten = titel_kandidaten(title) or [title]
|
kandidaten = titel_kandidaten(title) or [title]
|
||||||
movies = []
|
movies = []
|
||||||
|
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||||
|
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
treffer = self.tmdb.search_movie(kandidat)
|
treffer = self.tmdb.search_movie(kandidat)
|
||||||
if treffer and not movies:
|
if treffer and not movies:
|
||||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||||
|
movies_kandidat = kandidat
|
||||||
for movie in treffer:
|
for movie in treffer:
|
||||||
if movie.get("title", "").lower() == kandidat.lower():
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
@@ -441,16 +516,93 @@ class PreScan:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
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
|
# 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:
|
if not matched:
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
jikan_treffer = self.jikan.lookup(kandidat)
|
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
|
confidence = 0.85
|
||||||
metadata = jikan_treffer
|
metadata = jikan_treffer
|
||||||
matched = True
|
matched = True
|
||||||
break
|
else:
|
||||||
|
jikan_unscharf = jikan_treffer
|
||||||
|
break
|
||||||
|
|
||||||
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||||
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||||
@@ -464,6 +616,15 @@ class PreScan:
|
|||||||
matched = True
|
matched = True
|
||||||
break
|
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
|
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
|
||||||
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
|
||||||
if not matched and movies:
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,19 +2,104 @@
|
|||||||
|
|
||||||
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
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.
|
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||||
|
|
||||||
|
## Was diese Bremse ist — und was nicht
|
||||||
|
|
||||||
|
Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde).
|
||||||
|
Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle.
|
||||||
|
|
||||||
|
Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im
|
||||||
|
Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die
|
||||||
|
Folgen waren dem Commander als „wird oft neu geladen" aufgefallen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
# Default Rate Limit
|
# Wie viele Anfragen pro Minute und Client durchgehen.
|
||||||
MAX_REQUESTS_PER_MINUTE = 100
|
#
|
||||||
|
# ⚠️ 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)
|
# In-Memory Rate Limit Store (in Produktion mit Redis)
|
||||||
rate_limit_store: Dict[str, list] = defaultdict(list)
|
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:
|
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."""
|
"""Prüfe ob Client rate-limited ist."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|||||||
@@ -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
|
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).
|
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,
|
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
|
||||||
wo auch die Ampel läuft.
|
|
||||||
|
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 # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
if sys.platform == "win32": # pragma: no cover
|
|
||||||
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_importierbar_und_routen_verdrahtet():
|
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
|
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||||
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||||
"/system/keystore",
|
"/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"
|
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",
|
for pfad in ("/worker-setup/paket", "/worker-setup/windows",
|
||||||
"/worker-setup/windows-exe"):
|
"/worker-setup/windows-exe"):
|
||||||
assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"
|
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)."""
|
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
from makemkv_key import extract_key
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
# 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")
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
assert treffer == _KEY
|
assert treffer == _KEY
|
||||||
assert treffer.startswith("T-")
|
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)."""
|
"""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():
|
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)
|
quelle = inspect.getsource(mounts.mounten)
|
||||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
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("2005–2012") == 2005 # Serien-Zeitspanne
|
||||||
assert parse_year("N/A") is None
|
assert parse_year("N/A") is None
|
||||||
assert parse_year("") 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_pass http://api:8000/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
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:
|
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|||||||
@@ -6,19 +6,91 @@ import LogsPage from './pages/Logs'
|
|||||||
import AnleitungPage from './pages/Anleitung'
|
import AnleitungPage from './pages/Anleitung'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
import { api } from './lib/api'
|
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'
|
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')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
api.get('/setup')
|
let abgemeldet = false
|
||||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
let versuche = 0
|
||||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
|
||||||
|
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 }[] = [
|
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||||
@@ -28,7 +100,22 @@ export default function App() {
|
|||||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
{ 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)} />
|
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,20 +156,44 @@ export default function App() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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">
|
<VerbindungsAnzeige />
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
|
||||||
<span>ONLINE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content Viewport */}
|
{/* Main Content Viewport */}
|
||||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{currentPage === 'dashboard' && <Dashboard />}
|
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||||
{currentPage === 'logs' && <LogsPage />}
|
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
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>
|
</main>
|
||||||
</div>
|
</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 { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -8,6 +9,9 @@ interface ConfirmDialogProps {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
title: string
|
title: string
|
||||||
message: 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
|
confirmText?: string
|
||||||
cancelText?: string
|
cancelText?: string
|
||||||
type?: 'warning' | 'info' | 'success'
|
type?: 'warning' | 'info' | 'success'
|
||||||
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
extra,
|
||||||
confirmText = 'Bestätigen',
|
confirmText = 'Bestätigen',
|
||||||
cancelText = 'Abbrechen',
|
cancelText = 'Abbrechen',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
|
|||||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from './ui/Button'
|
|||||||
import { TypeBadge } from './ui/Badge'
|
import { TypeBadge } from './ui/Badge'
|
||||||
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||||
import MetadataKorrektur from './MetadataKorrektur'
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
|
||||||
interface DiscInfo {
|
interface DiscInfo {
|
||||||
title: string
|
title: string
|
||||||
@@ -35,6 +36,8 @@ interface Device {
|
|||||||
serial?: string
|
serial?: string
|
||||||
model?: string
|
model?: string
|
||||||
disc?: DiscInfo
|
disc?: DiscInfo
|
||||||
|
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
|
||||||
|
disc_wird_erkannt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function posterUrl(disc?: DiscInfo): string | null {
|
function posterUrl(disc?: DiscInfo): string | null {
|
||||||
@@ -63,6 +66,7 @@ export default function DeviceDiscovery() {
|
|||||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const strom = useStrom()
|
||||||
|
|
||||||
const refreshDevices = async (manuell = false) => {
|
const refreshDevices = async (manuell = false) => {
|
||||||
if (manuell) setRefreshing(true)
|
if (manuell) setRefreshing(true)
|
||||||
@@ -91,6 +95,10 @@ export default function DeviceDiscovery() {
|
|||||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
...(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`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
refreshDevices()
|
if (strom.devices !== null) {
|
||||||
const interval = setInterval(refreshDevices, 5000)
|
setDevices(strom.devices as Device[])
|
||||||
return () => clearInterval(interval)
|
setLoading(false)
|
||||||
}, [])
|
}
|
||||||
|
}, [strom.devices])
|
||||||
|
|
||||||
const QUELLEN_LABEL: Record<string, string> = {
|
const QUELLEN_LABEL: Record<string, string> = {
|
||||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||||
@@ -164,6 +181,34 @@ export default function DeviceDiscovery() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<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) */}
|
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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 { 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 { api } from '../lib/api'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||||
import { Button } from './ui/Button'
|
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.
|
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||||||
const PLATZ_WARNUNG_GB = 60
|
const PLATZ_WARNUNG_GB = 60
|
||||||
|
|
||||||
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
|
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
|
||||||
function presetsFuer(schwach: boolean) {
|
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
|
||||||
if (schwach) {
|
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
|
||||||
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
|
interface PresetUebersicht {
|
||||||
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
|
quelle: 'worker' | 'rueckfall'
|
||||||
// als H.265, dafür etwas größer).
|
hardware: string[]
|
||||||
return {
|
typen: Record<'uhd' | 'bluray' | 'dvd', {
|
||||||
transcodePreset: 'HQ 1080p30 Surround',
|
empfehlung: { preset: string, grund: string, gefunden: boolean }
|
||||||
transcodePresetBluray: 'HQ 1080p30 Surround',
|
}>
|
||||||
transcodePresetDvd: 'HQ 576p25 Surround',
|
}
|
||||||
transcodePresetUhd: PRESET_KEINE,
|
|
||||||
}
|
/** 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 {
|
return {
|
||||||
transcodePreset: 'H.265 MKV 1080p30',
|
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
|
||||||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
...(dvd ? { transcodePresetDvd: dvd } : {}),
|
||||||
transcodePresetDvd: 'H.265 MKV 576p25',
|
...(uhd ? { transcodePresetUhd: uhd } : {}),
|
||||||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||||
const [tmdbKey, setTmdbKey] = useState('')
|
const [tmdbKey, setTmdbKey] = useState('')
|
||||||
const [omdbKey, setOmdbKey] = useState('')
|
const [omdbKey, setOmdbKey] = useState('')
|
||||||
|
// 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 [mediaServer, setMediaServer] = useState('jellyfin')
|
||||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||||
|
const betrieb = useBetrieb()
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||||||
|
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||||||
const [geladen, setGeladen] = useState(false)
|
const [geladen, setGeladen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -77,15 +91,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let laeuft = true
|
let laeuft = true
|
||||||
const laden = async () => {
|
const laden = async () => {
|
||||||
const [caps, devs, info] = await Promise.allSettled([
|
const [caps, devs, info, pres] = await Promise.allSettled([
|
||||||
api.get('/capabilities'),
|
api.get('/capabilities'),
|
||||||
api.get('/devices'),
|
api.get('/devices'),
|
||||||
api.get('/system/info'),
|
api.get('/system/info'),
|
||||||
|
api.get('/presets'),
|
||||||
])
|
])
|
||||||
if (!laeuft) return
|
if (!laeuft) return
|
||||||
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
||||||
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
||||||
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
||||||
|
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
|
||||||
setGeladen(true)
|
setGeladen(true)
|
||||||
}
|
}
|
||||||
laden()
|
laden()
|
||||||
@@ -94,7 +110,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const schwach = schwacheEncoderCpu(workers)
|
const schwach = schwacheEncoderCpu(workers)
|
||||||
const presets = presetsFuer(schwach)
|
const presets = presetsFuer(presetInfo)
|
||||||
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
||||||
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
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,
|
...bestehende,
|
||||||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||||||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||||||
|
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||||||
mediaServer,
|
mediaServer,
|
||||||
transcodeEnabled,
|
transcodeEnabled,
|
||||||
...presets,
|
...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."
|
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
|
<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}
|
geladen={geladen}
|
||||||
gut={encoderWorker.length > 0}
|
gut={encoderWorker.length > 0}
|
||||||
gutText={encoderWorker.map(w => {
|
gutText={encoderWorker.map(w => {
|
||||||
@@ -192,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
.filter(Boolean).join(', ')
|
.filter(Boolean).join(', ')
|
||||||
return zusatz ? `${w.name} (${zusatz})` : w.name
|
return zusatz ? `${w.name} (${zusatz})` : w.name
|
||||||
}).join(' · ')}
|
}).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
|
<Zeile
|
||||||
titel="Freier Platz"
|
titel="Freier Platz"
|
||||||
@@ -232,6 +255,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ------------------------------------------- */}
|
{/* ---- Media-Server ------------------------------------------- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
<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>
|
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
|
<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>
|
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
|
||||||
{' '}dauern. Deshalb wird eingestellt:
|
{' '}dauern. Deshalb wird eingestellt:
|
||||||
</p>
|
</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">
|
<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:
|
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>{' '}
|
<strong>4K-UHD:</strong>{' '}
|
||||||
{presets.transcodePresetUhd === PRESET_KEINE
|
{presets.transcodePresetUhd === PRESET_KEINE
|
||||||
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
? '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>
|
||||||
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
|
||||||
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
|
|
||||||
</ul>
|
</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">
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
|
||||||
import { Card, CardHeader, CardTitle } from './ui/Card'
|
import { Card, CardHeader, CardTitle } from './ui/Card'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
|
||||||
interface LiveLogEntry {
|
interface LiveLogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,40 +11,14 @@ interface LiveLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LiveLogSection() {
|
export default function LiveLogSection() {
|
||||||
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
// Vorher: zwei eigene Taktgeber (alle 5 s /jobs und /logs) = 24 Anfragen pro
|
||||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
// Minute allein aus diesem Kasten. Jetzt kommt beides aus der einen offenen
|
||||||
|
// Verbindung, die der Provider haelt.
|
||||||
useEffect(() => {
|
const { jobs, logs } = useStrom()
|
||||||
const fetchJobs = async () => {
|
const currentJob =
|
||||||
try {
|
jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') ||
|
||||||
const response = await api.get('/jobs')
|
jobs.find((j: any) => j.status === 'pending') ||
|
||||||
const jobs = response.data
|
null
|
||||||
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)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getStatusColor = (level: string) => {
|
const getStatusColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
@@ -72,7 +45,10 @@ export default function LiveLogSection() {
|
|||||||
<CardTitle>Live-Log</CardTitle>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{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'}
|
: 'Kein aktiver Job'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 { 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 { api } from '../lib/api'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input, Select } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
|
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +22,17 @@ export interface RipOptionen {
|
|||||||
titles?: number[]
|
titles?: number[]
|
||||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
|
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.
|
// Ein Ablageziel aus GET /storage-targets.
|
||||||
@@ -35,6 +48,10 @@ interface WorkerWahl {
|
|||||||
node: string | null
|
node: string | null
|
||||||
online?: boolean
|
online?: boolean
|
||||||
encoders: string[]
|
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> = {
|
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 [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
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 () => {
|
const scanStarten = async () => {
|
||||||
if (!deviceId) return
|
if (!deviceId) return
|
||||||
@@ -102,6 +138,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const tracks: TitelInfo[] = r.data.tracks || []
|
const tracks: TitelInfo[] = r.data.tracks || []
|
||||||
setTitelListe(tracks)
|
setTitelListe(tracks)
|
||||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||||
|
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')
|
setScanStatus('done')
|
||||||
} else if (r.data.status === 'error') {
|
} else if (r.data.status === 'error') {
|
||||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||||
@@ -112,22 +158,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [scanStatus, deviceId])
|
}, [scanStatus, deviceId])
|
||||||
|
|
||||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
const betrieb = useBetrieb()
|
||||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
|
||||||
])
|
/*
|
||||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
* 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 [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
|
// 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,
|
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
|
||||||
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
|
||||||
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
|
||||||
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
||||||
const [arbeitsDir, setArbeitsDir] = useState('')
|
const [arbeitsDir, setArbeitsDir] = useState('')
|
||||||
const [standardArbeitsDir, setStandardArbeitsDir] = 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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -141,6 +207,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
|
setEigenesArbeitsZiel('')
|
||||||
|
setBrowserOffen(false)
|
||||||
|
setDiscSprachen(null)
|
||||||
|
setAudioWahl(new Set())
|
||||||
|
setUntertitelWahl(new Set())
|
||||||
api.get('/storage-targets')
|
api.get('/storage-targets')
|
||||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
.catch(() => setArbeitsZiele([]))
|
.catch(() => setArbeitsZiele([]))
|
||||||
@@ -152,14 +223,19 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
setStandardArbeitsDir((s.workDir || '').trim())
|
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([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ 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: '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 },
|
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
|
||||||
])
|
])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
laden('/app/media')
|
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
|
||||||
|
// Normalfall gar nicht.
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const laden = async (pfad: string) => {
|
const laden = async (pfad: string) => {
|
||||||
@@ -168,13 +244,53 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
setBrowseFiles(r.data.files || [])
|
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
setBrowseFiles([])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 handleSave = () => {
|
||||||
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||||
if (selectedType === 'series' && serienName.trim()) {
|
if (selectedType === 'series' && serienName.trim()) {
|
||||||
@@ -186,6 +302,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
}
|
}
|
||||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
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)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
@@ -285,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
onChange={e => setArbeitsDir(e.target.value)}
|
onChange={e => setArbeitsDir(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Standard aus den Einstellungen
|
Standard{arbeitsVorgabe ? ` — ${arbeitsVorgabe}` : ''}
|
||||||
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
|
||||||
</option>
|
</option>
|
||||||
{arbeitsZiele.map(z => (
|
{arbeitsZiele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<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` : ''}
|
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{eigenesArbeitsZiel && (
|
||||||
|
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={13} />
|
<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.
|
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +445,31 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
</div>
|
</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) */}
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
{deviceId && selectedType !== 'music' && (
|
{deviceId && selectedType !== 'music' && (
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
<div className="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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser */}
|
{/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides
|
||||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
|
||||||
<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">
|
{scanStatus === 'done' && discSprachen
|
||||||
<button
|
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
|
||||||
onClick={() => browseParent && laden(browseParent)}
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
disabled={!browseParent}
|
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||||
title="Ordner hoch"
|
Sprachen auf dieser Disc
|
||||||
>
|
</span>
|
||||||
<ArrowUp size={16} />
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</button>
|
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
|
||||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
{discSprachen.untertitel.length > 0
|
||||||
<Button
|
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
|
||||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
|
||||||
size="sm"
|
</p>
|
||||||
onClick={() => setCustomPath(browsePath)}
|
</div>
|
||||||
>
|
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
<div className="p-4 space-y-3">
|
||||||
</Button>
|
{(['audio', 'untertitel'] as const).map(art => {
|
||||||
</div>
|
const liste = discSprachen[art]
|
||||||
<div className="max-h-36 overflow-y-auto p-1">
|
if (liste.length === 0) return null
|
||||||
{browseDirs.map(d => (
|
const wahl = art === 'audio' ? audioWahl : untertitelWahl
|
||||||
<button
|
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
|
||||||
key={d.path}
|
return (
|
||||||
onClick={() => laden(d.path)}
|
<div key={art}>
|
||||||
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"
|
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
|
||||||
>
|
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
|
||||||
<Folder size={14} className="text-slate-400" />
|
</p>
|
||||||
{d.name}
|
<div className="flex flex-wrap gap-2">
|
||||||
</button>
|
{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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Gewähltes Ziel Display */}
|
{/* 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">
|
<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" />}
|
{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">
|
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
{zielPfad}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext'
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input } from './ui/Input'
|
import { Input } from './ui/Input'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -21,6 +22,7 @@ interface WorkerInfo {
|
|||||||
cpu_kerne?: string
|
cpu_kerne?: string
|
||||||
cpu_simd?: string
|
cpu_simd?: string
|
||||||
handbrake_encoder?: string
|
handbrake_encoder?: string
|
||||||
|
rippy_version?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ function relativeZeit(iso?: string): string {
|
|||||||
|
|
||||||
export default function WorkerVerwaltung() {
|
export default function WorkerVerwaltung() {
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [serverVersion, setServerVersion] = useState<string>('dev')
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
// 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.
|
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||||
const [rippyHost, setRippyHost] = useState('')
|
const [rippyHost, setRippyHost] = useState('')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const strom = useStrom()
|
||||||
|
|
||||||
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||||
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||||
@@ -68,16 +72,22 @@ export default function WorkerVerwaltung() {
|
|||||||
api.get('/capabilities')
|
api.get('/capabilities')
|
||||||
.then(r => {
|
.then(r => {
|
||||||
setWorkers(r.data.workers || [])
|
setWorkers(r.data.workers || [])
|
||||||
|
if (r.data.server_version) setServerVersion(r.data.server_version)
|
||||||
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||||
})
|
})
|
||||||
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
.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(() => {
|
useEffect(() => {
|
||||||
laden()
|
if (strom.workers.length || strom.verbunden) {
|
||||||
const interval = setInterval(laden, 15000)
|
setWorkers(strom.workers as WorkerInfo[])
|
||||||
return () => clearInterval(interval)
|
}
|
||||||
}, [])
|
}, [strom.workers, strom.verbunden])
|
||||||
|
|
||||||
const installBefehl = variante === 'linux'
|
const installBefehl = variante === 'linux'
|
||||||
? [
|
? [
|
||||||
@@ -146,6 +156,11 @@ export default function WorkerVerwaltung() {
|
|||||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||||
</p>
|
</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) && (
|
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TypeBadge({ type, className = '' }: 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] || {
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
label: type.toUpperCase(),
|
label: type ? type.toUpperCase() : 'DISC',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ import axios from 'axios'
|
|||||||
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
|
||||||
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
|
||||||
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
|
// 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 }
|
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
|
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
|
||||||
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
|
* 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 { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||||
import { PageHeader } from '../components/ui/PageHeader'
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
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 }) {
|
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
|
|||||||
|
|
||||||
export default function AnleitungPage() {
|
export default function AnleitungPage() {
|
||||||
const fett = "font-semibold text-slate-100"
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-6xl mx-auto">
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Abschnitt>
|
</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>
|
<p>
|
||||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
|
||||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen →
|
||||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
|
||||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
|
||||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
|
||||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
|
||||||
jedem Rip und bricht sonst mit Klartext ab.
|
|
||||||
</p>
|
</p>
|
||||||
</Abschnitt>
|
</Abschnitt>
|
||||||
|
|
||||||
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Abschnitt>
|
</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">
|
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||||
<p>
|
<p>
|
||||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
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).
|
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
|
||||||
</p>
|
</p>
|
||||||
</Abschnitt>
|
</Abschnitt>
|
||||||
|
)}
|
||||||
|
|
||||||
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import JobDetailModal from '../components/JobDetailModal'
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
import RetryDialog from '../components/RetryDialog'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -30,27 +33,85 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
|
||||||
|
retry_art?: string
|
||||||
meta?: JobMeta | null
|
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 {
|
interface SystemInfo {
|
||||||
api_version: string
|
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[] }[]
|
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 {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await api.get('/jobs')
|
const response = await api.get('/jobs')
|
||||||
return response.data
|
return Array.isArray(response.data) ? response.data : null
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +121,109 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
|
// 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 [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 { 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 {
|
try {
|
||||||
await api.delete(`/jobs/${job.id}`)
|
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
setRohdaten(r.data)
|
||||||
setJobs(await fetchJobs())
|
} 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) {
|
} catch (e: any) {
|
||||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -79,52 +234,111 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.delete('/jobs')
|
const r = await api.delete('/jobs')
|
||||||
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
|
||||||
setJobs(await fetchJobs())
|
const frisch = await fetchJobs()
|
||||||
|
if (frisch) setJobs(frisch)
|
||||||
} catch {
|
} catch {
|
||||||
toast('error', 'Aufräumen fehlgeschlagen')
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
setJobs(strom.jobs as Job[])
|
||||||
try {
|
setLoading(false)
|
||||||
const [jobsData, sysData] = await Promise.all([
|
}, [strom.jobs])
|
||||||
fetchJobs(),
|
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
|
||||||
])
|
|
||||||
setJobs(jobsData)
|
|
||||||
if (sysData) setSystemInfo(sysData)
|
|
||||||
|
|
||||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
useEffect(() => {
|
||||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
|
||||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
}, [strom.devices])
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadData()
|
useEffect(() => {
|
||||||
const interval = setInterval(loadData, 4000)
|
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
|
||||||
return () => clearInterval(interval)
|
}, [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 aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
const queueJobs = jobs.filter(j => j.status === 'pending')
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
const failedJobs = jobs.filter(j => j.status === 'failed')
|
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 mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
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 workerOnline = workersLive.filter(w => w.online)
|
||||||
const workerCount = systemInfo?.workers?.length || 1
|
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||||
|
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
/*
|
||||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
* Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||||
const y = 50 - (val / 100) * 40
|
*
|
||||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
|
||||||
}).join(' ')
|
* 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 => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
@@ -271,11 +485,17 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
<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="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="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 className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
Details
|
Details
|
||||||
@@ -360,6 +580,9 @@ export default function Dashboard() {
|
|||||||
style={{ width: `${job.progress}%` }}
|
style={{ width: `${job.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{job.eta_text && (
|
||||||
|
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
@@ -371,31 +594,63 @@ export default function Dashboard() {
|
|||||||
Details
|
Details
|
||||||
</Button>
|
</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' && (
|
{job.status === 'completed' && (
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
<Download size={13} /> Download
|
<Download size={13} /> Download
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.status === 'failed' && job.can_retry && (
|
{job.status === 'failed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => wiederholen(job)}
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
title="Fragt vorher, was genau wiederholt wird"
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} /> Neu
|
<RotateCcw size={13} />
|
||||||
|
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
|
||||||
|
: retryPlan(job) === 'rip' ? 'Neu rippen'
|
||||||
|
: 'Neu …'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => jobLoeschen(job)}
|
onClick={() => loeschenVorbereiten(job)}
|
||||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
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} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -421,58 +676,226 @@ export default function Dashboard() {
|
|||||||
<CardHeader className="border-b border-slate-800">
|
<CardHeader className="border-b border-slate-800">
|
||||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
<Server size={18} className="text-amber-400" />
|
<Server size={18} className="text-amber-400" />
|
||||||
Server Status (Echte Live-Daten)
|
Server-Status
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5 flex-1 flex items-center">
|
<CardContent className="p-5 flex-1 flex flex-col justify-center">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
<div className="space-y-4 w-full">
|
||||||
<div className="space-y-4 flex-1 w-full">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
|
||||||
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div>
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
|
||||||
<span className="text-slate-200">{workerCount} Worker Online</span>
|
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
|
||||||
</div>
|
<Activity size={14} className="text-amber-400" /> Gerade:
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
</span>
|
||||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
|
||||||
</div>
|
{phaseText}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
||||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
|
||||||
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
|
||||||
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
{/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
|
||||||
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
|
||||||
<path
|
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
|
||||||
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
|
||||||
strokeWidth="2.5"
|
keinen zweiten Worker, auf den man warten könnte, und
|
||||||
strokeLinecap="round"
|
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>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -548,6 +971,49 @@ export default function Dashboard() {
|
|||||||
confirmText="Aufräumen"
|
confirmText="Aufräumen"
|
||||||
type="warning"
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useToast } from '../context/ToastContext'
|
|||||||
import { PageHeader } from '../components/ui/PageHeader'
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
import { Card, CardContent } from '../components/ui/Card'
|
import { Card, CardContent } from '../components/ui/Card'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,6 +22,7 @@ export default function LogsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const strom = useStrom()
|
||||||
|
|
||||||
const refreshLogs = async (manuell = false) => {
|
const refreshLogs = async (manuell = false) => {
|
||||||
if (manuell) setRefreshing(true)
|
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(() => {
|
useEffect(() => {
|
||||||
refreshLogs()
|
if (!strom.logs.length) return
|
||||||
const interval = setInterval(refreshLogs, 10000)
|
setLogs((alt) => {
|
||||||
return () => clearInterval(interval)
|
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) => {
|
const getLevelColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
@@ -74,6 +85,24 @@ export default function LogsPage() {
|
|||||||
? logs
|
? logs
|
||||||
: logs.filter(log => log.level === filter || log.source === filter)
|
: 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 = {
|
const stats = {
|
||||||
info: logs.filter(l => l.level === 'info').length,
|
info: logs.filter(l => l.level === 'info').length,
|
||||||
warning: logs.filter(l => l.level === 'warning').length,
|
warning: logs.filter(l => l.level === 'warning').length,
|
||||||
@@ -162,6 +191,25 @@ export default function LogsPage() {
|
|||||||
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
|||||||