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 |
@@ -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.
|
||||||
|
|||||||
@@ -220,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`.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -95,6 +95,18 @@ fi
|
|||||||
sed -i '/^RIPPY_VERSION=/d' .env
|
sed -i '/^RIPPY_VERSION=/d' .env
|
||||||
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .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"
|
||||||
|
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:
|
||||||
@@ -54,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:
|
||||||
@@ -118,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,10 +17,19 @@ 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/
|
||||||
|
# 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/
|
COPY deploy/worker-windows/installer.py worker_dist/
|
||||||
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
|
# 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
|
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
|
||||||
|
|||||||
@@ -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]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,284 +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 hat_arbeit() -> bool:
|
|
||||||
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
|
|
||||||
|
|
||||||
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
|
|
||||||
`umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein
|
|
||||||
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
|
|
||||||
anlaufen.
|
|
||||||
"""
|
|
||||||
with engine.connect() as conn:
|
|
||||||
zeile = conn.execute(
|
|
||||||
select(jobs.c.id)
|
|
||||||
.where(jobs.c.status.in_(
|
|
||||||
("pending", "running", "ripping", "transcoding", "canceling")))
|
|
||||||
.limit(1)
|
|
||||||
).first()
|
|
||||||
return zeile is not None
|
|
||||||
|
|
||||||
|
|
||||||
def has_active_job(device: str) -> bool:
|
|
||||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
|
||||||
with engine.connect() as conn:
|
|
||||||
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,110 +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
|
|
||||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
|
||||||
CDS_NO_DISC = 1
|
|
||||||
CDS_TRAY_OPEN = 2
|
|
||||||
|
|
||||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
|
||||||
|
|
||||||
|
|
||||||
def eject(device_path: str) -> None:
|
|
||||||
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
|
|
||||||
|
|
||||||
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
|
|
||||||
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
|
|
||||||
und tut nichts. MakeMKV verriegelt die Tür während des Rips
|
|
||||||
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
|
|
||||||
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
|
|
||||||
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
|
|
||||||
Deshalb macht das Werkzeug `eject` immer beides.
|
|
||||||
|
|
||||||
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
|
|
||||||
zwischen den Containern; dort steht die ausführliche Herleitung.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
ioctl(fd, CDROM_LOCKDOOR, 0)
|
|
||||||
except OSError:
|
|
||||||
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
|
|
||||||
ioctl(fd, CDROMEJECT, 0)
|
|
||||||
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
|
|
||||||
# Slot-Laufwerk hat keine Schublade.
|
|
||||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
|
||||||
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
|
||||||
return
|
|
||||||
time.sleep(1)
|
|
||||||
raise OSError(
|
|
||||||
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
|
||||||
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
|
|||||||
return f"eta:{job_id}"
|
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,
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
jetzt: float, cache_get, cache_set) -> dict:
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||||
keine ETA, aber nichts scheitert.
|
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||||
"""
|
"""
|
||||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
return {"sekunden": -1, "text": ""}
|
return {"sekunden": -1, "text": ""}
|
||||||
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
|||||||
reihe = cache_get(k)
|
reihe = cache_get(k)
|
||||||
except Exception:
|
except Exception:
|
||||||
reihe = None
|
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)
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
try:
|
try:
|
||||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
cache_set(k, reihe, expire=86400)
|
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_REIHEN[k] = (jetzt, reihe)
|
||||||
|
_aufraeumen(jetzt)
|
||||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
|
|
||||||
|
|
||||||
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
|
|
||||||
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
|
|
||||||
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 läuft und
|
|
||||||
MakeMKV liest die Disc — sondern:
|
|
||||||
|
|
||||||
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
|
|
||||||
Die Windows-Version tut es.
|
|
||||||
|
|
||||||
Gemessen, nicht vermutet:
|
|
||||||
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
|
|
||||||
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
|
|
||||||
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
|
|
||||||
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
|
|
||||||
keine Verbindung, kein Schluessel.
|
|
||||||
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
|
|
||||||
latest HK to ...", Verbindung nach 185.84.108.20:443
|
|
||||||
(web33.majordomo.ru), _private_data.tar wächst — und die Disc geht auf
|
|
||||||
(TCOUNT:5, "Operation successfully completed").
|
|
||||||
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
|
|
||||||
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
|
|
||||||
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
|
|
||||||
|
|
||||||
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
|
|
||||||
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
|
|
||||||
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
|
|
||||||
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
|
|
||||||
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
|
|
||||||
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
|
|
||||||
|
|
||||||
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
|
|
||||||
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
|
|
||||||
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
|
|
||||||
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
|
|
||||||
|
|
||||||
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
|
|
||||||
Es verwaltet nur, was der Nutzer selbst mitbringt.
|
|
||||||
|
|
||||||
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
|
|
||||||
* Linux lädt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
|
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=25782
|
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=34022
|
|
||||||
* Schluessel liegen als hkd_*.bin in _private_data.tar:
|
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=32675
|
|
||||||
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
|
|
||||||
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
|
|
||||||
https://forum.makemkv.com/forum/viewtopic.php?t=30636
|
|
||||||
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
|
|
||||||
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
|
|
||||||
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
|
|
||||||
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
|
|
||||||
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
|
|
||||||
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
|
|
||||||
|
|
||||||
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
|
|
||||||
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
|
|
||||||
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tarfile
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
|
|
||||||
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
|
|
||||||
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
|
|
||||||
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
|
||||||
|
|
||||||
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
|
||||||
KEYDB_NAME = "KEYDB.cfg"
|
|
||||||
|
|
||||||
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
|
|
||||||
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
|
|
||||||
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
|
|
||||||
MAX_KEYDB_BYTES = 64 * 1024 * 1024
|
|
||||||
|
|
||||||
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
|
|
||||||
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
|
|
||||||
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
|
|
||||||
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(zeitstempel: float) -> str:
|
|
||||||
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
|
|
||||||
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
|
|
||||||
def keydb_pfad(daten_dir: str = None) -> str:
|
|
||||||
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
|
|
||||||
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
def zaehle_disc_eintraege(inhalt: str) -> int:
|
|
||||||
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
|
|
||||||
|
|
||||||
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
|
|
||||||
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
|
|
||||||
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
|
|
||||||
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
|
|
||||||
unterscheiden — sie wird deshalb auch genau so beschriftet.
|
|
||||||
"""
|
|
||||||
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
|
|
||||||
|
|
||||||
|
|
||||||
def keydb_pruefen(inhalt: str) -> str:
|
|
||||||
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
|
|
||||||
|
|
||||||
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
|
|
||||||
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
|
|
||||||
MakeMKV würde dann still weiter "volume key is unknown" melden.
|
|
||||||
"""
|
|
||||||
if not inhalt.strip():
|
|
||||||
return "Die Datei ist leer."
|
|
||||||
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
|
|
||||||
return (
|
|
||||||
"Die Datei ist größer als "
|
|
||||||
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
|
|
||||||
)
|
|
||||||
if inhalt.lstrip()[:1] == "<":
|
|
||||||
return (
|
|
||||||
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
|
|
||||||
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
|
|
||||||
)
|
|
||||||
if zaehle_disc_eintraege(inhalt) == 0:
|
|
||||||
return (
|
|
||||||
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
|
|
||||||
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def keydb_status(daten_dir: str = None) -> dict:
|
|
||||||
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
|
|
||||||
pfad = keydb_pfad(daten_dir)
|
|
||||||
try:
|
|
||||||
angaben = os.stat(pfad)
|
|
||||||
except OSError:
|
|
||||||
return {
|
|
||||||
"vorhanden": False,
|
|
||||||
"pfad": pfad,
|
|
||||||
"groesse_bytes": 0,
|
|
||||||
"eintraege": 0,
|
|
||||||
"geaendert": "",
|
|
||||||
}
|
|
||||||
eintraege = 0
|
|
||||||
try:
|
|
||||||
with open(pfad, encoding="utf-8", errors="replace") as datei:
|
|
||||||
eintraege = zaehle_disc_eintraege(datei.read())
|
|
||||||
except OSError:
|
|
||||||
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
|
|
||||||
return {
|
|
||||||
"vorhanden": True,
|
|
||||||
"pfad": pfad,
|
|
||||||
"groesse_bytes": angaben.st_size,
|
|
||||||
"eintraege": eintraege,
|
|
||||||
"geaendert": _iso(angaben.st_mtime),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
|
|
||||||
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
|
|
||||||
|
|
||||||
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
|
|
||||||
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
|
|
||||||
"""
|
|
||||||
pfad = keydb_pfad(daten_dir)
|
|
||||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
|
||||||
neben = pfad + ".neu"
|
|
||||||
try:
|
|
||||||
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
|
|
||||||
datei.write(inhalt)
|
|
||||||
os.replace(neben, pfad)
|
|
||||||
except OSError:
|
|
||||||
# Die Nebendatei nie liegen lassen: eine halb geschriebene
|
|
||||||
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
|
|
||||||
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
|
|
||||||
try:
|
|
||||||
os.remove(neben)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
return keydb_status(daten_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def keydb_loeschen(daten_dir: str = None) -> dict:
|
|
||||||
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
|
|
||||||
|
|
||||||
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
|
|
||||||
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
|
|
||||||
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
|
|
||||||
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.remove(keydb_pfad(daten_dir))
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return keydb_status(daten_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def ist_aacs_dump(name: str) -> bool:
|
|
||||||
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
|
|
||||||
|
|
||||||
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
|
|
||||||
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
|
|
||||||
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
name.endswith(".tgz")
|
|
||||||
and "/" not in name
|
|
||||||
and "\\" not in name
|
|
||||||
and not name.startswith(".")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dumps_auflisten(daten_dir: str = None) -> list:
|
|
||||||
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
|
|
||||||
ordner = daten_dir or DATEN_DIR
|
|
||||||
try:
|
|
||||||
namen = os.listdir(ordner)
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
liste = []
|
|
||||||
for name in namen:
|
|
||||||
if not ist_aacs_dump(name):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
angaben = os.stat(os.path.join(ordner, name))
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
liste.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"groesse_bytes": angaben.st_size,
|
|
||||||
"geaendert": _iso(angaben.st_mtime),
|
|
||||||
"_sort": angaben.st_mtime,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
|
|
||||||
for eintrag in liste:
|
|
||||||
del eintrag["_sort"]
|
|
||||||
return liste
|
|
||||||
|
|
||||||
|
|
||||||
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
|
|
||||||
#
|
|
||||||
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
|
|
||||||
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
|
|
||||||
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
|
|
||||||
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
|
|
||||||
|
|
||||||
PRIVATE_DATA_NAME = "_private_data.tar"
|
|
||||||
|
|
||||||
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
|
|
||||||
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
|
|
||||||
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
|
|
||||||
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
|
|
||||||
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def private_data_pfad(daten_dir: str = None) -> str:
|
|
||||||
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
|
|
||||||
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
def zaehle_schluessel(rohdaten: bytes) -> int:
|
|
||||||
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
|
|
||||||
|
|
||||||
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
|
|
||||||
Installation". Ein frischer, leerer Speicher enthält nur eine
|
|
||||||
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
|
|
||||||
unbekannte UHD-Disc scheitert.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
|
||||||
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
|
|
||||||
except (tarfile.TarError, OSError, EOFError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def private_data_pruefen(rohdaten: bytes) -> str:
|
|
||||||
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
|
|
||||||
|
|
||||||
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
|
|
||||||
voellig andere Datei hochladen, oder den Speicher einer Installation, die
|
|
||||||
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
|
|
||||||
niemand versteht warum).
|
|
||||||
"""
|
|
||||||
if not rohdaten:
|
|
||||||
return "Die Datei ist leer."
|
|
||||||
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
|
|
||||||
return (
|
|
||||||
"Die Datei ist größer als "
|
|
||||||
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
|
|
||||||
"MakeMKV-Schlüsselspeicher."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
|
|
||||||
namen = archiv.getnames()
|
|
||||||
except (tarfile.TarError, OSError, EOFError):
|
|
||||||
return (
|
|
||||||
"Das ist kein tar-Archiv. Erwartet wird die Datei "
|
|
||||||
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
|
|
||||||
)
|
|
||||||
if not any(name.startswith("hkd_") for name in namen):
|
|
||||||
return (
|
|
||||||
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
|
|
||||||
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
|
|
||||||
"noch keine geholt hat — öffne dort erst einmal eine Disc."
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def schluesselspeicher_status(daten_dir: str = None) -> dict:
|
|
||||||
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
|
|
||||||
pfad = private_data_pfad(daten_dir)
|
|
||||||
try:
|
|
||||||
angaben = os.stat(pfad)
|
|
||||||
with open(pfad, "rb") as datei:
|
|
||||||
schluessel = zaehle_schluessel(datei.read())
|
|
||||||
except OSError:
|
|
||||||
return {
|
|
||||||
"vorhanden": False,
|
|
||||||
"pfad": pfad,
|
|
||||||
"groesse_bytes": 0,
|
|
||||||
"schluessel": 0,
|
|
||||||
"geaendert": "",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"vorhanden": True,
|
|
||||||
"pfad": pfad,
|
|
||||||
"groesse_bytes": angaben.st_size,
|
|
||||||
"schluessel": schluessel,
|
|
||||||
"geaendert": _iso(angaben.st_mtime),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
|
|
||||||
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
|
|
||||||
|
|
||||||
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
|
|
||||||
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
|
|
||||||
"""
|
|
||||||
pfad = private_data_pfad(daten_dir)
|
|
||||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
|
||||||
neben = pfad + ".neu"
|
|
||||||
try:
|
|
||||||
with open(neben, "wb") as datei:
|
|
||||||
datei.write(rohdaten)
|
|
||||||
os.replace(neben, pfad)
|
|
||||||
except OSError:
|
|
||||||
try:
|
|
||||||
os.remove(neben)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
return schluesselspeicher_status(daten_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
|
|
||||||
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
|
|
||||||
|
|
||||||
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
|
|
||||||
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
|
|
||||||
bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
|
|
||||||
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
|
|
||||||
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
|
|
||||||
"""
|
|
||||||
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
|
|
||||||
if key:
|
|
||||||
zeilen.append('app_Key = "{}"'.format(key))
|
|
||||||
text = "\n".join(zeilen).strip("\n")
|
|
||||||
return text + "\n" if text else ""
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
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}$")
|
||||||
@@ -60,7 +62,8 @@ 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):
|
||||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
["timeout", "4", "ls", "-d", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=6
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unklar"
|
return "unklar"
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
|||||||
NICHTS = ""
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
def _meta(meta_json) -> dict:
|
def meta_von(meta_json) -> dict:
|
||||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
"""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):
|
if isinstance(meta_json, dict):
|
||||||
return meta_json
|
return meta_json
|
||||||
if not meta_json:
|
if not meta_json:
|
||||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
|||||||
"""
|
"""
|
||||||
if (job.get("status") or "") != "failed":
|
if (job.get("status") or "") != "failed":
|
||||||
return NICHTS
|
return NICHTS
|
||||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||||
if marke is True:
|
if marke is True:
|
||||||
return NEU_KOMPRIMIEREN
|
return NEU_KOMPRIMIEREN
|
||||||
if marke is False:
|
if marke is False:
|
||||||
|
|||||||
@@ -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,19 @@ 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).
|
||||||
|
|
||||||
@@ -74,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]:
|
||||||
@@ -273,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():
|
||||||
@@ -297,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
|
||||||
@@ -389,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
|
||||||
@@ -440,6 +516,55 @@ 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.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -38,18 +38,71 @@ 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]`.
|
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
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"
|
RAW_STANDARD = "/app/temp/raw"
|
||||||
MEDIA_ROOT = "/app/media"
|
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().
|
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||||
|
|
||||||
|
|
||||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
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).
|
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||||
|
|
||||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||||
@@ -65,13 +118,37 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
"""
|
"""
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return []
|
return []
|
||||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||||
wahl = (work_dir or "").strip().rstrip("/")
|
from rippy import pfade
|
||||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
|
||||||
orte.append(posixpath.join(wahl, job_id))
|
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 []:
|
for name in media_unterordner or []:
|
||||||
if name:
|
if name:
|
||||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||||
gesehen, eindeutig = set(), []
|
gesehen, eindeutig = set(), []
|
||||||
for ort in orte:
|
for ort in orte:
|
||||||
if ort not in gesehen:
|
if ort not in gesehen:
|
||||||
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
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:
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
|||||||
"""
|
"""
|
||||||
if not pfad:
|
if not pfad:
|
||||||
return "weg"
|
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
|
starten = laufen or subprocess.run
|
||||||
try:
|
try:
|
||||||
ergebnis = starten(
|
ergebnis = starten(
|
||||||
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
|||||||
return pruefen(pfad, laufen) == "da"
|
return pruefen(pfad, laufen) == "da"
|
||||||
|
|
||||||
|
|
||||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Die Orte, an denen wirklich etwas liegt.
|
"""Die Orte, an denen wirklich etwas liegt.
|
||||||
|
|
||||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||||
@@ -156,12 +271,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||||
"""
|
"""
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden = []
|
gefunden = []
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
try:
|
try:
|
||||||
if isdir(ort):
|
if isdir(ort):
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
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.
|
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||||
|
|
||||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||||
@@ -181,12 +298,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
|||||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||||
"""
|
"""
|
||||||
pruefe = pruefer or pruefen
|
pruefe = pruefer or pruefen
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden, unklar = [], False
|
gefunden, unklar = [], False
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
antwort = pruefe(ort)
|
antwort = pruefe(ort)
|
||||||
if antwort == "da":
|
if antwort == "da":
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for name in namen:
|
for name in namen:
|
||||||
voll = posixpath.join(pfad, name)
|
# `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:
|
try:
|
||||||
if isfile(voll):
|
if isfile(voll):
|
||||||
bytes_gesamt += getsize(voll)
|
bytes_gesamt += getsize(voll)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -246,3 +253,682 @@ def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
|
|||||||
zustand["da"] = True
|
zustand["da"] = True
|
||||||
main._mounts_nachsehen()
|
main._mounts_nachsehen()
|
||||||
assert any("antwortet wieder" in m for m in gelogged)
|
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)
|
||||||
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
|||||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
assert speicher == {} # nichts geschrieben
|
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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -145,7 +145,11 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
|||||||
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
||||||
# den Containern, deshalb per Pfad laden statt importieren.
|
# den Containern, deshalb per Pfad laden statt importieren.
|
||||||
worker_tasks = os.path.join(
|
worker_tasks = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
|
# 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)
|
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
||||||
quelltext = open(worker_tasks, encoding="utf-8").read()
|
quelltext = open(worker_tasks, encoding="utf-8").read()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -55,7 +55,11 @@ def test_der_schluessel_heisst_im_worker_genauso():
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
quelle = (
|
quelle = (
|
||||||
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
|
# 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")
|
).read_text(encoding="utf-8")
|
||||||
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
||||||
# ankommen.
|
# ankommen.
|
||||||
|
|||||||
@@ -144,3 +144,80 @@ def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
|||||||
assert ergebnis.confidence == 0.3
|
assert ergebnis.confidence == 0.3
|
||||||
assert ergebnis.metadata["type"] == "unknown"
|
assert ergebnis.metadata["type"] == "unknown"
|
||||||
assert ergebnis.title == "Nix Da"
|
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
|
||||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
|||||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
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():
|
def test_suche_liefert_nur_was_existiert():
|
||||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["movies", "rippy"],
|
listdir=lambda p: ["movies", "rippy"],
|
||||||
isdir=lambda p: p in vorhanden,
|
isdir=lambda p: p in vorhanden,
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
|||||||
assert f"/app/media/{JOB}" in orte
|
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():
|
def test_ohne_job_id_nichts():
|
||||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||||
|
|
||||||
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
|||||||
return p == f"/app/temp/raw/{JOB}"
|
return p == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
|||||||
raise OSError("kein /app/media")
|
raise OSError("kein /app/media")
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
|||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
|||||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [], "unklar": True}
|
assert e == {"pfade": [], "unklar": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
|
|||||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
|||||||
return "unklar" if "totes-nas" in pfad else "weg"
|
return "unklar" if "totes-nas" in pfad else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
assert e["unklar"] is True
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -124,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',
|
||||||
@@ -168,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, KeyRound } 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'
|
||||||
@@ -74,6 +75,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
const [makemkvKey, setMakemkvKey] = useState('')
|
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[]>([])
|
||||||
@@ -197,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 => {
|
||||||
@@ -207,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"
|
||||||
@@ -351,7 +359,7 @@ 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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 { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -157,12 +158,20 @@ 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 [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
@@ -181,6 +190,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
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
|
||||||
@@ -194,6 +207,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
|
setEigenesArbeitsZiel('')
|
||||||
setBrowserOffen(false)
|
setBrowserOffen(false)
|
||||||
setDiscSprachen(null)
|
setDiscSprachen(null)
|
||||||
setAudioWahl(new Set())
|
setAudioWahl(new Set())
|
||||||
@@ -213,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||||
setStandardAudio(codes(s.audioSprachen))
|
setStandardAudio(codes(s.audioSprachen))
|
||||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||||
const basis = s.outputDir || '/app/media'
|
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 },
|
||||||
@@ -240,6 +254,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||||
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||||
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
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
|
// 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
|
// 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.
|
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||||
@@ -378,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}>
|
||||||
@@ -387,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>
|
||||||
@@ -588,12 +615,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const auf = !browserOffen
|
const auf = !browserOffen
|
||||||
setBrowserOffen(auf)
|
setBrowserOffen(auf)
|
||||||
if (auf && browseDirs.length === 0) laden('/app/media')
|
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"
|
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} />}
|
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
Anderen Zielordner wählen (nur für diesen Rip)
|
{selectedType === 'music'
|
||||||
|
? 'Anderen Zielordner wählen (nur für diesen Rip)'
|
||||||
|
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{browserOffen && (
|
{browserOffen && (
|
||||||
@@ -607,14 +636,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
>
|
>
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{browsePath || 'Laufwerke'}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={!browsePath}
|
||||||
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||||
>
|
>
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
|
||||||
</Button>
|
</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>
|
||||||
<div className="max-h-36 overflow-y-auto p-1">
|
<div className="max-h-36 overflow-y-auto p-1">
|
||||||
{browseDirs.map(d => (
|
{browseDirs.map(d => (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -54,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).
|
||||||
@@ -76,11 +78,16 @@ export default function WorkerVerwaltung() {
|
|||||||
.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'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 RetryDialog from '../components/RetryDialog'
|
||||||
|
import { useStrom } from '../lib/useEventStream'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -42,7 +44,8 @@ interface Job {
|
|||||||
|
|
||||||
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[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,9 @@ interface LaufwerkLive {
|
|||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
disc?: { title?: string, year?: number | null, disc_type?: 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
|
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||||
@@ -126,6 +132,10 @@ export default function Dashboard() {
|
|||||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||||
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
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()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -232,64 +242,43 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ZWEI TAKTE statt einem.
|
* KEIN TAKTGEBER MEHR (Etappe V2-3).
|
||||||
*
|
*
|
||||||
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
|
* Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
|
||||||
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
|
* Worker und Ablagen — zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
|
||||||
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
|
* der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
|
||||||
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
|
* (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
|
||||||
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
|
* „es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
|
||||||
|
* Genau das hat der Commander als „wird oft neu geladen" gemeldet.
|
||||||
*
|
*
|
||||||
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
|
* Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
|
||||||
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
|
*
|
||||||
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
|
* ⚠️ DIE REGEL BLEIBT — sie ist nur umgezogen: Ein fehlender Wert im Strom
|
||||||
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
|
* heisst „nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
|
||||||
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
|
* jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
|
||||||
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
|
* letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
|
||||||
* 75/min sind damit 15 + 15/min geworden.
|
* „VERBINDUNG WEG" statt leerer Listen.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nichts = () => null
|
setJobs(strom.jobs as Job[])
|
||||||
|
setLoading(false)
|
||||||
|
}, [strom.jobs])
|
||||||
|
|
||||||
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
|
useEffect(() => {
|
||||||
const schnellLaden = async () => {
|
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
|
||||||
try {
|
}, [strom.devices])
|
||||||
const [jobsData, devData] = await Promise.all([
|
|
||||||
fetchJobs(),
|
|
||||||
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
|
||||||
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
|
||||||
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
|
||||||
])
|
|
||||||
if (jobsData) setJobs(jobsData)
|
|
||||||
if (devData) setLaufwerke(devData)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
|
useEffect(() => {
|
||||||
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
|
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
|
||||||
const langsamLaden = async () => {
|
}, [strom.workers, strom.verbunden])
|
||||||
const [sysData, capsData, ablData] = await Promise.all([
|
|
||||||
api.get('/system/info').then(r => r.data).catch(nichts),
|
|
||||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
|
||||||
// /system/info nur die Registrierung.
|
|
||||||
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
|
|
||||||
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
|
|
||||||
// Server-Status nur die Container-Platte (Commander-Befund).
|
|
||||||
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
|
|
||||||
])
|
|
||||||
if (sysData) setSystemInfo(sysData)
|
|
||||||
if (capsData) setWorkersLive(capsData)
|
|
||||||
if (ablData) setAblagen(ablData)
|
|
||||||
}
|
|
||||||
|
|
||||||
schnellLaden()
|
useEffect(() => {
|
||||||
langsamLaden()
|
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
|
||||||
const schnell = setInterval(schnellLaden, 4000)
|
}, [strom.systemInfo])
|
||||||
const langsam = setInterval(langsamLaden, 12000)
|
|
||||||
return () => { clearInterval(schnell); clearInterval(langsam) }
|
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')
|
||||||
@@ -343,7 +332,13 @@ export default function Dashboard() {
|
|||||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||||
: discImLaufwerk
|
: discImLaufwerk
|
||||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||||
: 'Bereit — kein Datenträger im Laufwerk'
|
// 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'
|
||||||
@@ -599,6 +594,38 @@ 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
|
||||||
@@ -697,7 +724,30 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
|
{/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
|
||||||
|
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
|
||||||
|
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
|
||||||
|
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
|
||||||
|
keinen zweiten Worker, auf den man warten könnte, und
|
||||||
|
kein docker compose, das man prüfen könnte. Die Meldung
|
||||||
|
war nicht nur unpassend, sie war falsch. */}
|
||||||
|
{!betrieb.kann.externe_worker ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-200">
|
||||||
|
auf diesem Rechner
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-mono text-slate-500">
|
||||||
|
{aktiverJob
|
||||||
|
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
|
||||||
|
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
@@ -711,7 +761,9 @@ export default function Dashboard() {
|
|||||||
{workerOnline.length === 0 ? (
|
{workerOnline.length === 0 ? (
|
||||||
<p className="text-[11px] font-mono text-rose-400">
|
<p className="text-[11px] font-mono text-rose-400">
|
||||||
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||||
Prüfen: <span className="text-slate-300">docker compose ps</span>
|
{betrieb.hilfe_befehl && (
|
||||||
|
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -732,15 +784,52 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
||||||
Vorher stand hier nur die Container-Platte; wer wissen
|
Vorher stand hier nur die Container-Platte; wer wissen
|
||||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
{/*
|
||||||
|
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">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
|
Container-Platte:
|
||||||
</span>
|
</span>
|
||||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||||
@@ -763,7 +852,16 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
||||||
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */}
|
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>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
@@ -797,6 +895,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { api } from '../lib/api'
|
|||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import StorageMounts from '../components/StorageMounts'
|
import StorageMounts from '../components/StorageMounts'
|
||||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
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 { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
|
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||||
@@ -48,7 +50,10 @@ const defaultSettings: SettingsState = {
|
|||||||
tmdbApiKey: '',
|
tmdbApiKey: '',
|
||||||
tvdbApiKey: '',
|
tvdbApiKey: '',
|
||||||
omdbApiKey: '',
|
omdbApiKey: '',
|
||||||
outputDir: '/app/media',
|
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
|
||||||
|
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
|
||||||
|
// beim Laden gesetzt.
|
||||||
|
outputDir: '',
|
||||||
movieDir: 'movies',
|
movieDir: 'movies',
|
||||||
seriesDir: 'series',
|
seriesDir: 'series',
|
||||||
musicDir: 'music',
|
musicDir: 'music',
|
||||||
@@ -187,6 +192,7 @@ function zeitLesbar(iso: string): string {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
|
const betrieb = useBetrieb()
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
@@ -208,6 +214,9 @@ export default function SettingsPage() {
|
|||||||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||||||
|
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
|
||||||
|
const [keyHolt, setKeyHolt] = useState(false)
|
||||||
|
const [keyMeldung, setKeyMeldung] = useState('')
|
||||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
@@ -248,6 +257,43 @@ export default function SettingsPage() {
|
|||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
|
||||||
|
*
|
||||||
|
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
|
||||||
|
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
|
||||||
|
* zwei Wege, ihn zu verlieren.
|
||||||
|
*/
|
||||||
|
const keyJetztHolen = async () => {
|
||||||
|
setKeyHolt(true)
|
||||||
|
setKeyMeldung('')
|
||||||
|
try {
|
||||||
|
const r = await api.post('/system/makemkv-key/holen')
|
||||||
|
const antwort = await api.get('/settings')
|
||||||
|
setSettings({ ...defaultSettings, ...antwort.data })
|
||||||
|
if (r.data?.angenommen === false) {
|
||||||
|
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
|
||||||
|
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
|
||||||
|
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
|
||||||
|
// trotzdem.
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
|
||||||
|
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
|
||||||
|
} else {
|
||||||
|
setKeyMeldung(r.data?.geaendert
|
||||||
|
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||||||
|
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||||||
|
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const grund = e?.response?.data?.detail
|
||||||
|
|| 'Das Forum ist nicht erreichbar.'
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + grund)
|
||||||
|
toast('error', grund)
|
||||||
|
} finally {
|
||||||
|
setKeyHolt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keydbHochladen = async (datei: File) => {
|
const keydbHochladen = async (datei: File) => {
|
||||||
setKeydbBusy(true)
|
setKeydbBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -485,7 +531,12 @@ export default function SettingsPage() {
|
|||||||
const istFreigabe = (pfad: string) =>
|
const istFreigabe = (pfad: string) =>
|
||||||
ziele.some(z => z.path === pfad && z.is_mount)
|
ziele.some(z => z.path === pfad && z.is_mount)
|
||||||
const ablageWarnung = (() => {
|
const ablageWarnung = (() => {
|
||||||
const ablage = settings.outputDir || '/app/media'
|
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
|
||||||
|
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
|
||||||
|
// es weder den einen noch die andere — der Satz stand am 28.08.2026
|
||||||
|
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
|
||||||
|
if (!betrieb.kann.externe_worker) return ''
|
||||||
|
const ablage = settings.outputDir || betrieb.ablage_vorgabe
|
||||||
const arbeit = settings.workDir
|
const arbeit = settings.workDir
|
||||||
if (!arbeit) {
|
if (!arbeit) {
|
||||||
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
||||||
@@ -505,16 +556,45 @@ export default function SettingsPage() {
|
|||||||
return ''
|
return ''
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Reiter haengen am BETRIEB.
|
||||||
|
*
|
||||||
|
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||||||
|
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||||||
|
*
|
||||||
|
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
|
||||||
|
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
|
||||||
|
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
|
||||||
|
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
|
||||||
|
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
|
||||||
|
* Bedienelemente, die ins Leere fuehren.
|
||||||
|
*/
|
||||||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||||
{ id: 'worker', label: 'Worker', icon: Cpu },
|
...(betrieb.kann.externe_worker
|
||||||
|
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
|
||||||
|
: []),
|
||||||
|
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
|
||||||
|
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
|
||||||
|
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
|
||||||
|
// das steckt weiter unten in einer eigenen Bedingung.
|
||||||
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||||||
{ id: 'apis', label: 'APIs', icon: Globe },
|
{ id: 'apis', label: 'APIs', icon: Globe },
|
||||||
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||||||
{ id: 'system', label: 'System', icon: Wrench },
|
{ id: 'system', label: 'System', icon: Wrench },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
|
||||||
|
*
|
||||||
|
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
|
||||||
|
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
|
||||||
|
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
|
||||||
|
*/
|
||||||
|
const sichtbareReiter = tabs.map(t => t.id)
|
||||||
|
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -533,7 +613,7 @@ export default function SettingsPage() {
|
|||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTab === tab.id
|
const isActive = aktiverReiter === tab.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -554,7 +634,7 @@ export default function SettingsPage() {
|
|||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<CardContent className="p-6 space-y-8">
|
<CardContent className="p-6 space-y-8">
|
||||||
{/* Ripping Settings */}
|
{/* Ripping Settings */}
|
||||||
{activeTab === 'ripping' && (
|
{aktiverReiter === 'ripping' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
@@ -697,7 +777,7 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verarbeitung */}
|
{/* Verarbeitung */}
|
||||||
{activeTab === 'verarbeitung' && (
|
{aktiverReiter === 'verarbeitung' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Cpu size={20} className="text-amber-500" />
|
<Cpu size={20} className="text-amber-500" />
|
||||||
@@ -901,12 +981,41 @@ export default function SettingsPage() {
|
|||||||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||||
inklusive freiem Platz.
|
inklusive freiem Platz.
|
||||||
*/}
|
*/}
|
||||||
|
{!betrieb.kann.container_pfade ? (
|
||||||
|
/*
|
||||||
|
* Ein echtes Pfadfeld statt einer Auswahl — dieselbe
|
||||||
|
* Entscheidung wie bei der Ablage darüber, und aus demselben
|
||||||
|
* Grund.
|
||||||
|
*
|
||||||
|
* Commander am 28.08.2026: „Wäre es möglich das
|
||||||
|
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||||||
|
*
|
||||||
|
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
|
||||||
|
* kommt aus /storage-targets, und das las `/app/media` — ein
|
||||||
|
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
|
||||||
|
* genau einem Eintrag, und der hieß „Container-Platte".
|
||||||
|
*
|
||||||
|
* Die Liste ist inzwischen repariert (sie bringt jetzt die
|
||||||
|
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
|
||||||
|
* „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
|
||||||
|
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||||
|
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||||
|
*/
|
||||||
|
<OrdnerWaehler
|
||||||
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
|
value={settings.workDir}
|
||||||
|
placeholder={betrieb.arbeits_vorgabe}
|
||||||
|
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option>
|
<option value="">
|
||||||
|
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||||
|
</option>
|
||||||
{ziele.map(z => (
|
{ziele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<option key={z.path} value={z.path}>
|
||||||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||||||
@@ -914,13 +1023,35 @@ export default function SettingsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
|
{!betrieb.kann.container_pfade && !!ziele.length && (
|
||||||
|
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
|
||||||
|
wählt anders — genau daran ist am 25.07. eine Platte
|
||||||
|
vollgelaufen. */
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ziele.map(z => (
|
||||||
|
<button
|
||||||
|
key={z.path}
|
||||||
|
onClick={() => handleChange('workDir', z.path)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||||
|
settings.workDir === z.path
|
||||||
|
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{z.name}{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||||||
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||||
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
|
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||||
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
|
{betrieb.kann.container_pfade
|
||||||
Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
|
||||||
|
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
|
||||||
{systemInfo?.plaetze?.length ? (
|
{systemInfo?.plaetze?.length ? (
|
||||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -930,7 +1061,7 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Worker */}
|
{/* Worker */}
|
||||||
{activeTab === 'worker' && (
|
{aktiverReiter === 'worker' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Cpu size={20} className="text-amber-500" />
|
<Cpu size={20} className="text-amber-500" />
|
||||||
@@ -941,7 +1072,7 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Speicherziele */}
|
{/* Speicherziele */}
|
||||||
{activeTab === 'speicherziele' && (
|
{aktiverReiter === 'speicherziele' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<HardDrive size={20} className="text-amber-500" />
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
@@ -961,6 +1092,19 @@ export default function SettingsPage() {
|
|||||||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
||||||
</h3>
|
</h3>
|
||||||
|
{!betrieb.kann.container_pfade ? (
|
||||||
|
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
|
||||||
|
Windows-PC gibt es keine Liste von Container-Zielen, aus
|
||||||
|
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||||
|
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||||
|
Bedienung, die nichts bedient. */
|
||||||
|
<OrdnerWaehler
|
||||||
|
label="Ordner"
|
||||||
|
value={settings.outputDir}
|
||||||
|
placeholder={betrieb.ablage_vorgabe}
|
||||||
|
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
label="Ablage"
|
label="Ablage"
|
||||||
value={settings.outputDir}
|
value={settings.outputDir}
|
||||||
@@ -974,6 +1118,7 @@ export default function SettingsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
||||||
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
||||||
@@ -1005,12 +1150,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StorageMounts />
|
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
|
||||||
|
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
|
||||||
|
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
|
||||||
|
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* APIs */}
|
{/* APIs */}
|
||||||
{activeTab === 'apis' && (
|
{aktiverReiter === 'apis' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Globe size={20} className="text-amber-500" />
|
<Globe size={20} className="text-amber-500" />
|
||||||
@@ -1070,7 +1218,7 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Benachrichtigungen */}
|
{/* Benachrichtigungen */}
|
||||||
{activeTab === 'benachrichtigungen' && (
|
{aktiverReiter === 'benachrichtigungen' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<AlertCircle size={20} className="text-amber-500" />
|
<AlertCircle size={20} className="text-amber-500" />
|
||||||
@@ -1120,7 +1268,7 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
{activeTab === 'system' && (
|
{aktiverReiter === 'system' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Wrench size={20} className="text-amber-500" />
|
<Wrench size={20} className="text-amber-500" />
|
||||||
@@ -1188,13 +1336,44 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
{/*
|
||||||
type="password"
|
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||||||
label="MakeMKV Beta-Key"
|
|
||||||
value={settings.makemkvAppKey}
|
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
glaube das web rippy kann das." Er hatte recht:
|
||||||
/>
|
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||||||
|
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||||||
|
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||||||
|
Eine Automatik, die man nicht sehen kann, ist für den
|
||||||
|
Benutzer keine.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="MakeMKV Beta-Key"
|
||||||
|
value={settings.makemkvAppKey}
|
||||||
|
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||||
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={keyHolt}
|
||||||
|
onClick={keyJetztHolen}
|
||||||
|
>
|
||||||
|
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||||||
|
</Button>
|
||||||
|
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||||||
|
+ 'der Key wechselt etwa monatlich.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY docker/worker/ .
|
COPY docker/worker/ .
|
||||||
|
|
||||||
|
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
|
||||||
|
# neben den Worker-Modulen. Celery startet mit /app als Arbeitsverzeichnis,
|
||||||
|
# damit findet "import rippy" das Paket ohne PYTHONPATH.
|
||||||
|
COPY src/rippy ./rippy
|
||||||
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -8,10 +8,49 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
|
||||||
|
def _extern(werte=None, container=None) -> str:
|
||||||
|
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||||
|
|
||||||
|
Drei Fälle, und nur einer davon ist „ja":
|
||||||
|
|
||||||
|
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||||
|
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||||
|
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||||
|
etwas zu übersetzen wäre.
|
||||||
|
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||||
|
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if container is None:
|
||||||
|
container = betrieb.im_container()
|
||||||
|
if container:
|
||||||
|
return "nein"
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def _hb() -> str:
|
||||||
|
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||||
|
|
||||||
|
Bis V2-4 stand hier `shutil.which("HandBrakeCLI")`. Das findet unter
|
||||||
|
Windows nichts, auch wenn HandBrake installiert ist (Program Files statt
|
||||||
|
PATH). Die Folge waere gewesen: Rippy meldet "keine Encoder gefunden" auf
|
||||||
|
einem Rechner, auf dem alles da ist — und die Encoder-Auswahl im UI bliebe
|
||||||
|
leer, ohne dass jemand den Grund saehe.
|
||||||
|
"""
|
||||||
|
return werkzeuge.finden("handbrake")
|
||||||
|
|
||||||
|
|
||||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||||
@@ -148,12 +187,12 @@ def parse_preset_liste(text: str) -> list:
|
|||||||
|
|
||||||
def hole_handbrake_presets() -> list:
|
def hole_handbrake_presets() -> list:
|
||||||
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
if not shutil.which("HandBrakeCLI"):
|
if not _hb():
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--preset-list"],
|
[_hb(), "--preset-list"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, timeout=30, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -262,12 +301,12 @@ def cpu_merkmale() -> tuple:
|
|||||||
|
|
||||||
def hole_handbrake_hilfe() -> str:
|
def hole_handbrake_hilfe() -> str:
|
||||||
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
if not shutil.which("HandBrakeCLI"):
|
if not _hb():
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
|
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER
|
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||||
)
|
)
|
||||||
return (aus.stdout or "") + (aus.stderr or "")
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -330,8 +369,18 @@ def werkzeug_versionen() -> dict:
|
|||||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||||
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||||
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
#
|
||||||
"extern": "nein" if os.path.isdir("/app") else "ja",
|
# ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
|
||||||
|
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
|
||||||
|
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
|
||||||
|
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
|
||||||
|
# Maschine, auf der es weder Container noch Freigabe gibt.
|
||||||
|
#
|
||||||
|
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
|
||||||
|
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
|
||||||
|
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
|
||||||
|
# Ordnername.
|
||||||
|
"extern": _extern(),
|
||||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||||
# grundsätzlich nicht komprimieren.
|
# grundsätzlich nicht komprimieren.
|
||||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||||
@@ -349,13 +398,13 @@ def werkzeug_versionen() -> dict:
|
|||||||
presets = hole_handbrake_presets()
|
presets = hole_handbrake_presets()
|
||||||
if presets:
|
if presets:
|
||||||
info["presets"] = presets
|
info["presets"] = presets
|
||||||
if shutil.which("makemkvcon"):
|
if werkzeuge.finden("makemkv"):
|
||||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
if shutil.which("HandBrakeCLI"):
|
if _hb():
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--version"],
|
[_hb(), "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, timeout=15, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -364,8 +413,8 @@ def werkzeug_versionen() -> dict:
|
|||||||
info["handbrake"] = "installiert"
|
info["handbrake"] = "installiert"
|
||||||
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
|
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
|
||||||
try:
|
try:
|
||||||
import db
|
from rippy import store as db
|
||||||
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip()
|
ui_key = (db.get_settings(bei_fehler_leer=True).get("makemkvAppKey") or "").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
ui_key = ""
|
ui_key = ""
|
||||||
if ui_key:
|
if ui_key:
|
||||||
@@ -385,11 +434,25 @@ def werkzeug_versionen() -> dict:
|
|||||||
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
|
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
|
||||||
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
|
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
|
||||||
try:
|
try:
|
||||||
import makemkv_daten
|
from rippy.rip import makemkv_daten
|
||||||
daten_dir = makemkv_daten.DATEN_DIR
|
daten_dir = makemkv_daten.DATEN_DIR
|
||||||
except Exception:
|
except Exception:
|
||||||
daten_dir = ""
|
daten_dir = ""
|
||||||
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
# ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
|
||||||
|
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
|
||||||
|
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
|
||||||
|
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
|
||||||
|
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
|
||||||
|
#
|
||||||
|
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
|
||||||
|
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
|
||||||
|
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
|
||||||
|
from rippy import betrieb
|
||||||
|
|
||||||
|
_im_container = betrieb.im_container()
|
||||||
|
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
|
||||||
|
else os.path.isdir(daten_dir))
|
||||||
|
if werkzeuge.finden("makemkv") and daten_da:
|
||||||
try:
|
try:
|
||||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ celery_app.conf.update(
|
|||||||
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
|
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
|
||||||
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
||||||
import caps # noqa: E402
|
import caps # noqa: E402
|
||||||
import db # noqa: E402
|
from rippy import store as db # noqa: E402
|
||||||
import zombies # noqa: E402
|
import zombies # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
|
||||||
|
|
||||||
Die Tabellendefinition existiert bewusst identisch in API und Worker
|
|
||||||
(docker/api/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
|
||||||
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column,
|
|
||||||
DateTime,
|
|
||||||
Integer,
|
|
||||||
MetaData,
|
|
||||||
String,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
create_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
|
||||||
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
|
||||||
)
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
||||||
metadata = MetaData()
|
|
||||||
|
|
||||||
jobs = Table(
|
|
||||||
"jobs",
|
|
||||||
metadata,
|
|
||||||
Column("id", String(36), primary_key=True),
|
|
||||||
Column("disc_type", String(16)),
|
|
||||||
Column("device", String(64)),
|
|
||||||
Column("title", String(255)),
|
|
||||||
Column("status", String(16), nullable=False, server_default="pending"),
|
|
||||||
Column("progress", Integer, nullable=False, server_default="0"),
|
|
||||||
Column("output_path", Text),
|
|
||||||
Column("target_dir", String(255)),
|
|
||||||
Column("error", Text),
|
|
||||||
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
|
|
||||||
Column("created_at", DateTime(timezone=True)),
|
|
||||||
Column("finished_at", DateTime(timezone=True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
logs = Table(
|
|
||||||
"logs",
|
|
||||||
metadata,
|
|
||||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
||||||
Column("ts", DateTime(timezone=True)),
|
|
||||||
Column("level", String(16)),
|
|
||||||
Column("source", String(32)),
|
|
||||||
Column("message", Text),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
|
||||||
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
|
|
||||||
|
|
||||||
Die ALTERs stehen identisch in docker/api/db.py — wer zuerst startet,
|
|
||||||
migriert; der andere findet die Spalten dann bereits vor.
|
|
||||||
"""
|
|
||||||
metadata.create_all(engine)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
conn.exec_driver_sql(
|
|
||||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
|
||||||
)
|
|
||||||
conn.exec_driver_sql(
|
|
||||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
|
||||||
)
|
|
||||||
conn.exec_driver_sql(
|
|
||||||
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
settings_table = Table(
|
|
||||||
"settings",
|
|
||||||
metadata,
|
|
||||||
Column("key", String(64), primary_key=True),
|
|
||||||
Column("value", Text),
|
|
||||||
)
|
|
||||||
|
|
||||||
workers = Table(
|
|
||||||
"workers",
|
|
||||||
metadata,
|
|
||||||
Column("name", String(128), primary_key=True),
|
|
||||||
Column("encoders", Text),
|
|
||||||
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
|
||||||
Column("last_seen", DateTime(timezone=True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
|
||||||
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
payload = json.dumps(encoder_liste)
|
|
||||||
info_payload = json.dumps(info or {})
|
|
||||||
with engine.begin() as conn:
|
|
||||||
vorhanden = conn.execute(
|
|
||||||
workers.select().where(workers.c.name == name)
|
|
||||||
).first()
|
|
||||||
if vorhanden:
|
|
||||||
conn.execute(
|
|
||||||
workers.update().where(workers.c.name == name).values(
|
|
||||||
encoders=payload, info=info_payload, last_seen=utcnow()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
workers.insert().values(
|
|
||||||
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_settings(key: str = "ui") -> dict:
|
|
||||||
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
try:
|
|
||||||
with engine.connect() as conn:
|
|
||||||
zeile = conn.execute(
|
|
||||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
|
||||||
).first()
|
|
||||||
if zeile and zeile[0]:
|
|
||||||
return json.loads(zeile[0])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_job(job_id: str) -> dict:
|
|
||||||
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
|
||||||
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
zeile = conn.execute(
|
|
||||||
select(jobs).where(jobs.c.id == job_id)
|
|
||||||
).mappings().first()
|
|
||||||
return dict(zeile) if zeile else None
|
|
||||||
|
|
||||||
|
|
||||||
def save_settings(werte: dict, key: str = "ui") -> None:
|
|
||||||
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
|
||||||
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
payload = json.dumps(werte)
|
|
||||||
with engine.begin() as conn:
|
|
||||||
vorhanden = conn.execute(
|
|
||||||
select(settings_table.c.key).where(settings_table.c.key == key)
|
|
||||||
).first()
|
|
||||||
if vorhanden:
|
|
||||||
conn.execute(
|
|
||||||
settings_table.update()
|
|
||||||
.where(settings_table.c.key == key)
|
|
||||||
.values(value=payload)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(settings_table.insert().values(key=key, value=payload))
|
|
||||||
|
|
||||||
|
|
||||||
def get_job_status(job_id: str) -> str:
|
|
||||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
zeile = conn.execute(
|
|
||||||
select(jobs.c.status).where(jobs.c.id == job_id)
|
|
||||||
).first()
|
|
||||||
return zeile[0] if zeile else ""
|
|
||||||
|
|
||||||
|
|
||||||
def list_jobs_mit_status(stati) -> list:
|
|
||||||
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
|
|
||||||
|
|
||||||
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
|
||||||
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile — die
|
|
||||||
Erkennung braucht nichts weiter.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
zeilen = conn.execute(
|
|
||||||
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
|
||||||
.where(jobs.c.status.in_(list(stati)))
|
|
||||||
).mappings().all()
|
|
||||||
return [dict(z) for z in zeilen]
|
|
||||||
|
|
||||||
|
|
||||||
def zaehle_online_worker(sekunden: int = 120) -> int:
|
|
||||||
"""Wie viele Worker gelten laut Herzschlag gerade als online?
|
|
||||||
|
|
||||||
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
|
|
||||||
melden sich weniger Worker als bekannt sind, ist die Auskunft
|
|
||||||
unvollständig — dann wird NICHTS als Leiche gewertet.
|
|
||||||
"""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
grenze = utcnow() - timedelta(seconds=sekunden)
|
|
||||||
with engine.connect() as conn:
|
|
||||||
anzahl = conn.execute(
|
|
||||||
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
|
||||||
).scalar()
|
|
||||||
return int(anzahl or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def meta_merken(job_id: str, **felder) -> None:
|
|
||||||
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
|
||||||
|
|
||||||
`update_job(meta=...)` würde die Spalte ersetzen — Poster, Jahr, Titel-Wahl
|
|
||||||
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
|
|
||||||
schreiben.
|
|
||||||
|
|
||||||
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
|
|
||||||
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern — im
|
|
||||||
schlimmsten Fall fehlt die Marke und der „Neu"-Knopf fragt nach.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
|
|
||||||
try:
|
|
||||||
zeile = get_job(job_id)
|
|
||||||
if not zeile:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
vorher = json.loads(zeile.get("meta") or "{}")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
vorher = {}
|
|
||||||
if not isinstance(vorher, dict):
|
|
||||||
vorher = {}
|
|
||||||
vorher.update(felder)
|
|
||||||
update_job(job_id, meta=json.dumps(vorher))
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, **fields) -> None:
|
|
||||||
with engine.begin() as conn:
|
|
||||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
|
||||||
|
|
||||||
|
|
||||||
def add_log(level: str, source: str, message: str) -> None:
|
|
||||||
with engine.begin() as conn:
|
|
||||||
conn.execute(
|
|
||||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
|
||||||
)
|
|
||||||
@@ -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"
|
|
||||||
@@ -188,7 +188,7 @@ def deinstallieren() -> None:
|
|||||||
|
|
||||||
def _bruecke_bauen():
|
def _bruecke_bauen():
|
||||||
try:
|
try:
|
||||||
import db
|
from rippy import store as db
|
||||||
import logbruecke
|
import logbruecke
|
||||||
|
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|||||||
@@ -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]}"
|
|
||||||
)
|
|
||||||
@@ -17,7 +17,54 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
# Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
|
||||||
|
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
|
||||||
|
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
|
||||||
|
#
|
||||||
|
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
|
||||||
|
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
|
||||||
|
from rippy.drives.linux import ( # noqa: F401
|
||||||
|
AUSWURF_WARTEN_SEKUNDEN,
|
||||||
|
CDROM_DRIVE_STATUS,
|
||||||
|
CDROM_LOCKDOOR,
|
||||||
|
CDROMCLOSETRAY,
|
||||||
|
CDROMEJECT,
|
||||||
|
CDS_DISC_OK,
|
||||||
|
CDS_DRIVE_NOT_READY,
|
||||||
|
CDS_NO_DISC,
|
||||||
|
CDS_TRAY_OPEN,
|
||||||
|
_auswurf_geglueckt,
|
||||||
|
)
|
||||||
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||||
|
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||||
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeug(name: str) -> str:
|
||||||
|
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
|
||||||
|
|
||||||
|
## Warum hier nicht mehr `shutil.which` steht
|
||||||
|
|
||||||
|
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
|
||||||
|
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das — dort liegen
|
||||||
|
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
|
||||||
|
beide installiert sind: Windows-Programme liegen in Program Files und
|
||||||
|
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen —
|
||||||
|
MakeMKV lag da, und `shutil.which` sah es nicht.
|
||||||
|
|
||||||
|
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
|
||||||
|
der Unterschied zwischen "laeuft" und "laeuft nicht".
|
||||||
|
|
||||||
|
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
|
||||||
|
damit NICHTS — subprocess findet das Programm dort ueber den PATH wie
|
||||||
|
bisher.
|
||||||
|
"""
|
||||||
|
return werkzeuge.finden(name) or _NACKTER_NAME[name]
|
||||||
|
|
||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
@@ -27,114 +74,9 @@ class RipAbbruch(Exception):
|
|||||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||||
|
|
||||||
|
|
||||||
# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py
|
|
||||||
CDROMEJECT = 0x5309
|
|
||||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
|
||||||
CDROMCLOSETRAY = 0x5319
|
|
||||||
|
|
||||||
# Antworten von CDROM_DRIVE_STATUS (cdrom.h)
|
|
||||||
CDS_NO_DISC = 1
|
|
||||||
CDS_TRAY_OPEN = 2
|
|
||||||
CDS_DRIVE_NOT_READY = 3
|
|
||||||
CDS_DISC_OK = 4
|
|
||||||
|
|
||||||
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
|
|
||||||
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
|
|
||||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
|
||||||
|
|
||||||
|
|
||||||
def _auswurf_geglueckt(status: int) -> bool:
|
|
||||||
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
|
|
||||||
|
|
||||||
Sowohl „Schublade offen" als auch „kein Datenträger" zählen: Ein
|
|
||||||
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
|
|
||||||
"""
|
|
||||||
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
|
|
||||||
|
|
||||||
|
|
||||||
def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None,
|
|
||||||
schliessen=None, warten=None) -> bool:
|
|
||||||
"""Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE.
|
|
||||||
|
|
||||||
## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen)
|
|
||||||
|
|
||||||
Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert
|
|
||||||
nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt:
|
|
||||||
|
|
||||||
wirf_disc_aus("/dev/sr0") → True
|
|
||||||
CDROM_DRIVE_STATUS danach → 4 (Disc drin)
|
|
||||||
|
|
||||||
Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV
|
|
||||||
verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und
|
|
||||||
entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf
|
|
||||||
trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides:
|
|
||||||
erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc:
|
|
||||||
|
|
||||||
CDROM_LOCKDOOR 0 + CDROMEJECT → Status 2 (SCHUBLADE OFFEN)
|
|
||||||
|
|
||||||
## Und deshalb wird das Ergebnis geprüft, nicht geglaubt
|
|
||||||
|
|
||||||
Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier
|
|
||||||
„Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung
|
|
||||||
wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im
|
|
||||||
Log stand „Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts;
|
|
||||||
gefragt wird jetzt das Laufwerk.
|
|
||||||
|
|
||||||
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
|
|
||||||
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
|
|
||||||
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
|
|
||||||
|
|
||||||
Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
|
|
||||||
"""
|
|
||||||
if ioctl_fn is None:
|
|
||||||
try:
|
|
||||||
from fcntl import ioctl
|
|
||||||
except ImportError: # Windows — dieser Worker rippt nie
|
|
||||||
return False
|
|
||||||
ioctl_fn = ioctl
|
|
||||||
if warten is None:
|
|
||||||
import time
|
|
||||||
warten = time.sleep
|
|
||||||
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
|
|
||||||
schliessen = schliessen or os.close
|
|
||||||
|
|
||||||
try:
|
|
||||||
fd = oeffnen(device_path)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
|
|
||||||
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
|
|
||||||
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
|
|
||||||
try:
|
|
||||||
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ioctl_fn(fd, CDROMEJECT, 0)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
|
|
||||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
|
||||||
try:
|
|
||||||
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
warten(1)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
schliessen(fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def check_makemkv_installed() -> bool:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
return bool(werkzeuge.finden("makemkv"))
|
||||||
|
|
||||||
|
|
||||||
def check_abcde_installed() -> bool:
|
def check_abcde_installed() -> bool:
|
||||||
@@ -147,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
|
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||||
|
#:
|
||||||
|
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||||
|
#: deutschsprachiges MakeMKV):
|
||||||
|
#:
|
||||||
|
#: Encountered 29 errors of type 'Read Error' - see
|
||||||
|
#: http://www.makemkv.com/errors/read/
|
||||||
|
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||||
|
#:
|
||||||
|
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||||
|
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||||
|
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||||
|
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||||
|
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||||
|
#: hierher statt dieser Textsuche.
|
||||||
|
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -158,12 +118,12 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
|||||||
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
werkzeug("makemkv"),
|
||||||
"-r",
|
"-r",
|
||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
makemkv_quelle(device_path),
|
||||||
str(titel),
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
@@ -318,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
|||||||
wäre eine Minute Wartezeit für nichts.
|
wäre eine Minute Wartezeit für nichts.
|
||||||
"""
|
"""
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
ausgabe = ergebnis.stdout or ""
|
ausgabe = text_von(ergebnis.stdout)
|
||||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
alle_dateien = []
|
alle_dateien = []
|
||||||
|
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||||
|
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||||
|
lesefehler = False
|
||||||
for index, nr in enumerate(titel_liste):
|
for index, nr in enumerate(titel_liste):
|
||||||
def anteilig(p, _i=index):
|
def anteilig(p, _i=index):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -339,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
ergebnis["lesefehler"] = lesefehler
|
||||||
return ergebnis
|
return ergebnis
|
||||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||||
|
"lesefehler": lesefehler}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict, meta: dict = None):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
@@ -391,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
|||||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return {}
|
return {}
|
||||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||||
|
|
||||||
|
|
||||||
def parse_scan_dauer(ausgabe: str) -> int:
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
@@ -420,8 +386,8 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -467,7 +433,7 @@ def get_progress_from_prgv(line: str) -> int:
|
|||||||
|
|
||||||
def check_handbrake_installed() -> bool:
|
def check_handbrake_installed() -> bool:
|
||||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||||
return shutil.which("HandBrakeCLI") is not None
|
return bool(werkzeuge.finden("handbrake"))
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||||
@@ -535,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
|||||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||||
|
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||||
|
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||||
|
".mov": "av_mov", ".webm": "av_webm"}
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||||
preset: str = DEFAULT_HB_PRESET,
|
preset: str = DEFAULT_HB_PRESET,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||||
@@ -560,20 +532,55 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||||
|
|
||||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
"HandBrakeCLI",
|
werkzeug("handbrake"),
|
||||||
"--input", input_path,
|
"--input", input_path,
|
||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--preset", preset,
|
||||||
]
|
]
|
||||||
|
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||||
|
# endete mit Code 0"
|
||||||
|
#
|
||||||
|
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||||
|
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||||
|
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||||
|
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||||
|
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||||
|
# nichts und nannte das „fehlgeschlagen".
|
||||||
|
#
|
||||||
|
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||||
|
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||||
|
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||||
|
# die auseinanderlaufen koennen.
|
||||||
|
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||||
|
if format_name:
|
||||||
|
befehl += ["--format", format_name]
|
||||||
audio = [s for s in (audio_sprachen or []) if s]
|
audio = [s for s in (audio_sprachen or []) if s]
|
||||||
if audio:
|
if audio:
|
||||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||||
befehl.append("--first-audio")
|
befehl.append("--first-audio")
|
||||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||||
|
#
|
||||||
|
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||||
|
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||||
|
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||||
|
#
|
||||||
|
# unknown option (--audio-codec)
|
||||||
|
# HandBrake has exited. $? = 0
|
||||||
|
#
|
||||||
|
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||||
|
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||||
|
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||||
|
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||||
|
# festschreibt, statt ihn zu finden.
|
||||||
|
#
|
||||||
|
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||||
|
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||||
if untertitel:
|
if untertitel:
|
||||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||||
@@ -638,6 +645,23 @@ def sprachliste(wert) -> list:
|
|||||||
return sauber
|
return sauber
|
||||||
|
|
||||||
|
|
||||||
|
def _datei_daneben(erwartet: str) -> str:
|
||||||
|
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||||
|
|
||||||
|
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||||
|
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||||
|
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||||
|
HandBrake ueberhaupt schreiben kann.
|
||||||
|
"""
|
||||||
|
ordner = os.path.dirname(erwartet)
|
||||||
|
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||||
|
for endung in FORMATE:
|
||||||
|
kandidat = os.path.join(ordner, stamm + endung)
|
||||||
|
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||||
|
return kandidat
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||||
progress_cb=None, abbruch_cb=None,
|
progress_cb=None, abbruch_cb=None,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||||
@@ -661,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
audio_sprachen, untertitel_sprachen),
|
audio_sprachen, untertitel_sprachen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
|
**HB_LESEN,
|
||||||
)
|
)
|
||||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -690,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
|||||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||||
|
HB_ZEILEN_PUFFER = 12
|
||||||
|
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||||
|
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||||
|
HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannter_schalter(zeile: str) -> str:
|
||||||
|
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||||
|
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||||
|
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||||
|
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||||
|
Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited.
|
||||||
|
$? = 0
|
||||||
|
|
||||||
|
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||||
|
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||||
|
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||||
|
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||||
|
|
||||||
|
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||||
|
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||||
|
statt wieder einen ganzen Rip zu kosten.
|
||||||
|
"""
|
||||||
|
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||||
|
return treffer.group(1).strip() if treffer else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hb_schluss(zeilen) -> str:
|
||||||
|
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||||
|
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||||
|
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||||
|
Ausgabe, nur hoerte niemand zu.
|
||||||
|
"""
|
||||||
|
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||||
|
if not sauber:
|
||||||
|
return ""
|
||||||
|
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
falsches_preset = ""
|
falsches_preset = ""
|
||||||
|
falscher_schalter = ""
|
||||||
|
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||||
|
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||||
|
letzte_zeilen = []
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -701,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if line.strip():
|
||||||
|
letzte_zeilen.append(line)
|
||||||
|
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||||
|
del letzte_zeilen[0]
|
||||||
if not falsches_preset:
|
if not falsches_preset:
|
||||||
falsches_preset = unbekanntes_preset(line)
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
if not falscher_schalter:
|
||||||
|
falscher_schalter = unbekannter_schalter(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -718,6 +805,57 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
|
||||||
|
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||||
|
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||||
|
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||||
|
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||||
|
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||||
|
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||||
|
if falscher_schalter:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||||
|
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||||
|
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||||
|
+ hb_schluss(letzte_zeilen)
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||||
|
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||||
|
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||||
|
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||||
|
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||||
|
#
|
||||||
|
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||||
|
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||||
|
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||||
|
if process.returncode == 0:
|
||||||
|
daneben = _datei_daneben(output_path)
|
||||||
|
if daneben:
|
||||||
|
return {"status": "success", "output_path": daneben,
|
||||||
|
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||||
|
"(der Container kommt aus dem Preset)."
|
||||||
|
% (os.path.basename(daneben),
|
||||||
|
os.path.basename(output_path))}
|
||||||
|
# Frueher stand hier geraten „Meist ist der Zielordner nicht
|
||||||
|
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
|
||||||
|
# und leer, der Grund ein falscher Schalter) — und die Vermutung
|
||||||
|
# schickte die Suche in die falsche Richtung. Jetzt wird der
|
||||||
|
# Ordner GENANNT und HandBrake selbst zitiert.
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||||
|
"entstanden (Zielordner: %s)."
|
||||||
|
% os.path.dirname(output_path)
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
|
"return_code": 0,
|
||||||
|
}
|
||||||
if falsches_preset:
|
if falsches_preset:
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -732,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
def disc_fehlt(device_path: str, zustand=None) -> str:
|
||||||
|
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
|
||||||
|
|
||||||
|
## Warum vorher nachgesehen wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander bekam:
|
||||||
|
|
||||||
|
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||||
|
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||||
|
|
||||||
|
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
|
||||||
|
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
|
||||||
|
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
|
||||||
|
und meldet einen Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
|
||||||
|
|
||||||
|
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
|
||||||
|
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
|
||||||
|
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||||
|
"""
|
||||||
|
if zustand is None:
|
||||||
|
try:
|
||||||
|
from rippy import drives as _schicht
|
||||||
|
|
||||||
|
zustand = _schicht.treiber().drive_status
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
stand = zustand(device_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
if stand == CDS_NO_DISC:
|
||||||
|
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
|
||||||
|
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
|
||||||
|
"wieder hinein." % device_path)
|
||||||
|
if stand == CDS_TRAY_OPEN:
|
||||||
|
return "Die Schublade von %s ist offen." % device_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||||
log_cb=None) -> dict:
|
log_cb=None) -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||||
@@ -784,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
|
fehlt = disc_fehlt(device_path)
|
||||||
|
if fehlt:
|
||||||
|
return {"status": "error", "error": fehlt}
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -791,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
build_makemkv_cmd(device_path, output_dir, titel),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||||
bufsize=1,
|
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
|
||||||
|
# schrieb aber UTF-8 — daraus wurde beim Commander
|
||||||
|
# „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
|
||||||
|
# nicht (Python warnt und puffert doch). Ungepuffert kommt
|
||||||
|
# jede Fortschrittszeile sofort an — bei einem Rip, der
|
||||||
|
# Stunden laeuft, ist das der Unterschied zwischen einem
|
||||||
|
# Balken und einem eingefrorenen Fenster.
|
||||||
|
bufsize=0,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||||
|
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||||
|
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||||
|
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||||
|
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||||
|
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||||
|
lesefehler = False
|
||||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
# (Befund 24.07., Summer-Wars-UHD).
|
# (Befund 24.07., Summer-Wars-UHD).
|
||||||
kritische_meldungen = []
|
kritische_meldungen = []
|
||||||
|
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
|
||||||
|
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
|
||||||
|
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
|
||||||
|
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
|
||||||
|
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
|
||||||
|
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
|
||||||
KRITISCH = (
|
KRITISCH = (
|
||||||
"volume key is unknown",
|
"volume key is unknown",
|
||||||
"evaluation period has expired",
|
"evaluation period has expired",
|
||||||
@@ -809,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"Too old version",
|
"Too old version",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for rohzeile in process.stdout:
|
||||||
|
line = text_von(rohzeile)
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0:
|
if progress >= 0:
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -819,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if meldung is None:
|
if meldung is None:
|
||||||
continue
|
continue
|
||||||
code, letzte_meldung = meldung
|
code, letzte_meldung = meldung
|
||||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
if LESEFEHLER_MARKE in letzte_meldung:
|
||||||
|
lesefehler = True
|
||||||
|
if code in KRITISCHE_CODES:
|
||||||
|
kritische_meldungen.append(
|
||||||
|
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||||
|
elif any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
kritische_meldungen.append(letzte_meldung)
|
kritische_meldungen.append(letzte_meldung)
|
||||||
if log_cb:
|
if log_cb:
|
||||||
try:
|
try:
|
||||||
@@ -847,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"files": mkv_dateien,
|
"files": mkv_dateien,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
|
"lesefehler": lesefehler,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"lesefehler": lesefehler,
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
@@ -899,10 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
|
||||||
|
|
||||||
|
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||||
|
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||||
|
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||||
|
|
||||||
|
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||||
|
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||||
|
"""
|
||||||
|
from rippy.drives import windows as win
|
||||||
|
from rippy.rip import audio_cd
|
||||||
|
|
||||||
|
class Leser:
|
||||||
|
toc = staticmethod(win.audio_toc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||||
|
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||||
|
fortschritt)
|
||||||
|
|
||||||
|
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||||
|
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||||
|
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||||
|
# Disc, die niemand kennt.
|
||||||
|
from rippy.rip import musicbrainz_cd
|
||||||
|
|
||||||
|
try:
|
||||||
|
spuren = win.audio_toc(device_path)
|
||||||
|
antwort = musicbrainz_cd.abfragen(
|
||||||
|
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
antwort = None
|
||||||
|
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||||
|
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||||
|
|
||||||
|
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||||
|
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||||
|
werkzeuge.finden("flac"),
|
||||||
|
fortschritt=progress_cb, tags_je_spur=tags)
|
||||||
|
if auswerfen and ergebnis.get("status") == "success":
|
||||||
|
wirf_disc_aus(device_path)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||||
auswerfen: bool = True) -> dict:
|
auswerfen: bool = True) -> dict:
|
||||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||||
Auswurf" (abcde macht das selbst per -x)."""
|
Auswurf" (abcde macht das selbst per -x)."""
|
||||||
|
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||||
|
#
|
||||||
|
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||||
|
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||||
|
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||||
|
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||||
|
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||||
|
#
|
||||||
|
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||||
|
# Wochen so im SAVEPOINT.
|
||||||
|
if os.name == "nt":
|
||||||
|
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||||
|
auswerfen)
|
||||||
|
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
||||||
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
||||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
|||||||
for pfad in fest:
|
for pfad in fest:
|
||||||
if os.path.isfile(pfad):
|
if os.path.isfile(pfad):
|
||||||
return pfad
|
return pfad
|
||||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||||
|
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||||
|
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||||
|
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||||
|
from rippy.tools import katalog
|
||||||
|
|
||||||
|
return (katalog.finden("makemkv")
|
||||||
|
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||||
|
|
||||||
|
|
||||||
def parse_laufwerke(ausgabe: str) -> list:
|
def parse_laufwerke(ausgabe: str) -> list:
|
||||||
|
|||||||
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
|||||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||||
|
#
|
||||||
|
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||||
|
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||||
|
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||||
|
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||||
|
# Freigabe gibt.
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_ist_nichts_extern():
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({}, container=True) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||||
|
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||||
|
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||||
|
container=False) == "ja"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
|
|||||||
zwei noch nie angelegte Ordner.
|
zwei noch nie angelegte Ordner.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
|
|
||||||
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
||||||
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
|||||||
return tasks._erreichbarkeit_pruefen(
|
return tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||||
|
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||||
|
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||||
|
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||||
|
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||||
|
fremd=not im_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
|||||||
meldung = tasks._erreichbarkeit_pruefen(
|
meldung = tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||||
"/app/media/movies/X", "/app/media/movies/X",
|
"/app/media/movies/X", "/app/media/movies/X",
|
||||||
|
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||||
|
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||||
|
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||||
|
fremd=True,
|
||||||
)
|
)
|
||||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||||
assert "von keinem Eintrag übersetzt" in meldung
|
assert "von keinem Eintrag übersetzt" in meldung
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def test_matche_episoden_ohne_treffer_gibt_none():
|
|||||||
|
|
||||||
|
|
||||||
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||||
from tasks import pfad_lokal
|
from ablauf import pfad_lokal
|
||||||
|
|
||||||
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
||||||
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
||||||
@@ -121,30 +121,39 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||||
|
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||||
|
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||||
|
# prompt rot geworden).
|
||||||
|
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||||
|
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
niemand gefragt wird.
|
niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
einst = {"workDir": "/app/media/movies"}
|
einst = {"workDir": "/app/media/movies"}
|
||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
||||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
||||||
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
||||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|||||||
|
|
||||||
|
|
||||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
import tasks
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
w = CONTAINER
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||||
|
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||||
|
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||||
|
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||||
|
# sogar eine AUSDRUECKLICHE Wahl.
|
||||||
|
#
|
||||||
|
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||||
|
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||||
|
# ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||||
|
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||||
|
deshalb still verworfen."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||||
|
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||||
|
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||||
|
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||||
|
Wurzel."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
unc = r"\\192.168.179.62\rippy\movies"
|
||||||
|
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||||
|
assert ziel.startswith(NATIV[0])
|
||||||
|
assert "/app/" not in ziel
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
|
|
||||||
class FakeDb:
|
class FakeDb:
|
||||||
|
|||||||
@@ -13,14 +13,22 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
hb_schluss,
|
||||||
parse_msg,
|
parse_msg,
|
||||||
|
unbekannter_schalter,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_makemkv_cmd_vollstaendig():
|
def test_makemkv_cmd_vollstaendig():
|
||||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||||
assert cmd[0] == "makemkvcon"
|
# Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter
|
||||||
|
# Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem
|
||||||
|
# nackten Namen faende `subprocess` es dort nie. Im Container bleibt es
|
||||||
|
# der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf
|
||||||
|
# dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig;
|
||||||
|
# gepruefet wird deshalb, WORAUF der Befehl zeigt.
|
||||||
|
assert "makemkvcon" in cmd[0].lower()
|
||||||
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
||||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||||
@@ -34,15 +42,65 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
||||||
für Blu-rays prinzipiell funktionsunfähig)."""
|
für Blu-rays prinzipiell funktionsunfähig)."""
|
||||||
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
||||||
assert cmd[0] == "HandBrakeCLI"
|
# Wie beim MakeMKV-Befehl: seit V2-4 steht hier der GEFUNDENE Pfad. Unter
|
||||||
|
# Windows liegt HandBrakeCLI in Program Files oder in Rippys eigenem
|
||||||
|
# Werkzeug-Ordner und nicht im PATH — mit dem nackten Namen faende
|
||||||
|
# `subprocess` es dort nie.
|
||||||
|
assert "handbrakecli" in cmd[0].lower()
|
||||||
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
assert "--preset" in cmd
|
||||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||||
assert "--audio-codec" in cmd
|
assert "--aencoder" in cmd
|
||||||
assert "--all-subtitles" in cmd
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_kennt_keinen_schalter_audio_codec():
|
||||||
|
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
|
||||||
|
|
||||||
|
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
|
||||||
|
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
|
||||||
|
das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit
|
||||||
|
ins Leere, ohne dass irgendwo ein Grund stand.
|
||||||
|
|
||||||
|
⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
|
||||||
|
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
|
||||||
|
ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten
|
||||||
|
Programm gemessen, nicht aus dem Gedächtnis behauptet.
|
||||||
|
"""
|
||||||
|
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
|
||||||
|
assert "--audio-codec" not in cmd
|
||||||
|
assert cmd[cmd.index("--aencoder") + 1] == "copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_wird_erkannt():
|
||||||
|
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"unknown option (--audio-codec)") == "--audio-codec"
|
||||||
|
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
|
||||||
|
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekannter_schalter("") == ""
|
||||||
|
assert unbekannter_schalter(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
|
||||||
|
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
|
||||||
|
0 ist, sagt er nichts (Befund 30.08.2026)."""
|
||||||
|
assert hb_schluss([]) == ""
|
||||||
|
assert hb_schluss(None) == ""
|
||||||
|
assert hb_schluss([" ", " "]) == ""
|
||||||
|
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
|
||||||
|
assert "fünf" in text and "vier" in text
|
||||||
|
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
|
||||||
|
assert "eins" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
@@ -365,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|||||||
assert ergebnis["return_code"] == 3
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
|
||||||
|
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und
|
||||||
|
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
|
||||||
|
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
|
||||||
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
@@ -380,91 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
|
||||||
|
assert "irgendwas ganz anderes" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
|
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
|
||||||
|
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
|
||||||
|
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
|
||||||
|
|
||||||
|
Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg,
|
||||||
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
|
|
||||||
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
|
|
||||||
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
|
|
||||||
"""
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
|
class FakeProcess:
|
||||||
"aufrufe": []}
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"[13:30:54] hb_init: starting libhb thread\n",
|
||||||
|
"unknown option (--audio-codec)\n",
|
||||||
|
"HandBrake has exited.\n",
|
||||||
|
])
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
def ioctl_fn(fd, befehl, arg=0):
|
def kill(self):
|
||||||
zustand["aufrufe"].append(befehl)
|
pass
|
||||||
if befehl == ripping.CDROM_LOCKDOOR:
|
|
||||||
if not kennt_lockdoor:
|
def wait(self):
|
||||||
raise OSError("ioctl unbekannt")
|
|
||||||
zustand["verriegelt"] = bool(arg)
|
|
||||||
return 0
|
return 0
|
||||||
if befehl == ripping.CDROMEJECT:
|
|
||||||
if not zustand["verriegelt"]:
|
|
||||||
zustand["status"] = ripping.CDS_TRAY_OPEN
|
|
||||||
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
|
|
||||||
if befehl == ripping.CDROM_DRIVE_STATUS:
|
|
||||||
return zustand["status"]
|
|
||||||
raise OSError("unerwartetes ioctl")
|
|
||||||
|
|
||||||
return zustand, ioctl_fn
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
import ripping
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
zustand, ioctl_fn = _laufwerk(verriegelt=True)
|
|
||||||
ok = ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None)
|
|
||||||
assert ok is True
|
|
||||||
assert zustand["status"] == ripping.CDS_TRAY_OPEN
|
|
||||||
# Reihenfolge: entriegeln VOR auswerfen
|
|
||||||
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
|
|
||||||
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
|
|
||||||
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
|
|
||||||
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
|
|
||||||
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
|
|
||||||
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
|
|
||||||
ok = ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None)
|
|
||||||
assert ok is False
|
|
||||||
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
|
|
||||||
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
|
|
||||||
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_ohne_laufwerk_wirft_nicht():
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
def oeffnen_kaputt(p):
|
|
||||||
raise OSError("kein Laufwerk")
|
|
||||||
|
|
||||||
assert ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None) is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
@@ -593,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
|
|||||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der
|
||||||
|
# Disk schlug fehl — keine MKV-Datei entstanden"
|
||||||
|
#
|
||||||
|
# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem
|
||||||
|
# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein
|
||||||
|
# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen
|
||||||
|
# Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_wird_vorher_erkannt():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_NO_DISC
|
||||||
|
|
||||||
|
grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC)
|
||||||
|
assert "Kein Datentraeger" in grund
|
||||||
|
assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offene_schublade_auch():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_disc_wird_nicht_gemeckert():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_DISC_OK
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT():
|
||||||
|
"""„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das
|
||||||
|
Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
def wirft(p):
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=wirft) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_makemkv_sieht_vorher_nach():
|
||||||
|
"""Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft
|
||||||
|
man weiterhin zwei Minuten ins Leere."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
quelle = inspect.getsource(ripping.run_makemkv)
|
||||||
|
vor_popen = quelle.index("subprocess.Popen")
|
||||||
|
assert "disc_fehlt(" in quelle[:vor_popen], \
|
||||||
|
"die Disc-Pruefung gehoert vor den makemkvcon-Start"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_sammelmeldung_5010_wird_erklaert():
|
||||||
|
"""5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts."""
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES
|
||||||
|
|
||||||
|
assert 5010 in KRITISCHE_CODES
|
||||||
|
assert "Disc" in KRITISCHE_CODES[5010]
|
||||||
|
|||||||
@@ -221,7 +221,11 @@ def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
|
# 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.
|
||||||
|
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ablauf.py")
|
||||||
quelle = open(pfad, encoding="utf-8").read()
|
quelle = open(pfad, encoding="utf-8").read()
|
||||||
|
|
||||||
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
|
|
||||||
|
|
||||||
## Der Befund, der das nötig gemacht hat (26.07.2026)
|
|
||||||
|
|
||||||
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
|
|
||||||
recht — es war kein Geist, sondern der Herzschlag des Workers.
|
|
||||||
|
|
||||||
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
|
|
||||||
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
|
|
||||||
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
|
|
||||||
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
|
|
||||||
kein Fenster.
|
|
||||||
|
|
||||||
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
|
|
||||||
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
|
|
||||||
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
|
|
||||||
Schüben — genau das „alle paar Sekunden".
|
|
||||||
|
|
||||||
## Warum eine eigene Datei
|
|
||||||
|
|
||||||
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
|
|
||||||
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
|
|
||||||
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
|
|
||||||
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
|
||||||
derselbe Code auf beiden Seiten läuft.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
|
||||||
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
|
||||||
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
||||||