Compare commits
75
Commits
a14449ef85
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77ab0302 | ||
|
|
cedab61ddf | ||
|
|
6f230cbd7f | ||
|
|
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 |
+11
-1
@@ -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/
|
||||||
|
|||||||
@@ -54,3 +54,4 @@ logs/
|
|||||||
# Data
|
# Data
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1493
File diff suppressed because it is too large
Load Diff
+49
@@ -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`.
|
||||||
|
|||||||
+140
@@ -570,8 +570,148 @@ heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Rippy v2 — Etappen V2-0 bis V2-7 (Plan vom 28.08.2026)
|
||||||
|
|
||||||
|
**Vollständige Spezifikation: `KONZEPT-V2.md`.** Hier stehen nur die Etappen
|
||||||
|
und ihre Abnahmekriterien.
|
||||||
|
|
||||||
|
**Ziel:** drei Betriebsmodi statt eines — Docker (wie heute, aufgeräumt), eine
|
||||||
|
native Windows-App ohne Docker, eine Headless-Linux-Anwendung. Alle drei mit
|
||||||
|
demselben Webinterface, alle drei aus EINEM Kern.
|
||||||
|
|
||||||
|
**Grundregel für den ganzen Weg:** Jede Etappe endet mit grüner Ampel und einem
|
||||||
|
lauffähigen System. v1 läuft bis V2-5 produktiv weiter — auf der VM liegen echte
|
||||||
|
Medien.
|
||||||
|
|
||||||
|
### V2-0: Monorepo — ein Paket statt Zwillingen
|
||||||
|
- [ ] `src/rippy/` als gemeinsames Paket anlegen (api UND worker importieren daraus)
|
||||||
|
- [ ] Die byte-identischen Zwillinge zusammenführen: `detection.py`,
|
||||||
|
`makemkv_daten.py`, `notify.py` — je zweimal im Repo
|
||||||
|
- [ ] Tests wandern mit; `test_zwillinge_sind_byteweise_identisch` wird
|
||||||
|
gegenstandslos und weicht einem Test, der die EINE Quelle prüft
|
||||||
|
- [ ] Beide Dockerfiles kopieren `src/rippy`, `PYTHONPATH` gesetzt
|
||||||
|
- **Verhalten unverändert.** Kein Funktionsgewinn, reine Struktur.
|
||||||
|
- **Fertig, wenn:** Ampel grün, `docker compose up` verhält sich wie vorher,
|
||||||
|
kein Modul mehr doppelt im Repo.
|
||||||
|
|
||||||
|
### V2-1: Ports einziehen
|
||||||
|
- [ ] `Store`, `Queue`, `Bus`, `Drives` als Python-`Protocol`
|
||||||
|
- [ ] v1-Verhalten läuft über die Treiber Postgres / Celery / Redis / Linux
|
||||||
|
- [ ] Kein Funktionsgewinn — reine Verdrahtung
|
||||||
|
- **Fertig, wenn:** Ampel grün und ein Rip auf der VM durchläuft.
|
||||||
|
|
||||||
|
### V2-2: Standalone (SQLite + lokale Queue)
|
||||||
|
- [ ] SQLite-Treiber mit WAL, `busy_timeout`, ein Schreiber-Kontext
|
||||||
|
- [ ] Alembic statt handgeschriebener `ALTER TABLE … IF NOT EXISTS`
|
||||||
|
(das ist Postgres-only und bricht auf SQLite)
|
||||||
|
- [ ] LocalQueue: Auftrags-Tabelle + Lease + Prozesspool, kein Broker
|
||||||
|
- [ ] Lease ersetzt `zombies.py` — abgelaufene Lease = Auftrag ist frei
|
||||||
|
- [ ] `rippyd --profil standalone`
|
||||||
|
- **Fertig, wenn:** ein DVD-Rip komplett ohne Postgres, Redis und Docker läuft.
|
||||||
|
|
||||||
|
### V2-3: Echtzeit — Polling raus
|
||||||
|
- [ ] Bus-Treiber (asyncio in-process / Redis Pub/Sub)
|
||||||
|
- [ ] `GET /api/v2/events` als SSE-Strom, `snapshot` beim Verbinden,
|
||||||
|
lückenlose `seq` für Wiederaufnahme
|
||||||
|
- [ ] UI auf einen `useEventStream`-Haken; die neun `setInterval` fliegen raus
|
||||||
|
- [ ] `test_grenze_deckt_die_eigene_last_ab` auf die neuen Zahlen ziehen
|
||||||
|
- **Fertig, wenn:** Grundlast ≈ 0/min gemessen (heute ≈ 133/min bei einem Tab
|
||||||
|
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
||||||
|
|
||||||
|
### V2-4: Windows nativ
|
||||||
|
- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
|
||||||
|
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
|
||||||
|
MMC `GET CONFIGURATION` für den Disc-Typ)
|
||||||
|
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
|
||||||
|
vorerst über den Poll aus `drives/detection.py`
|
||||||
|
- [x] Dienst + Fenster als GETRENNTE Prozesse; **WebView2-Fenster**
|
||||||
|
(`fenster.py`, Entscheid 4 in `KONZEPT-V2.md` § 10)
|
||||||
|
- [x] Eintrag in „Programme und Features" und im Taskmanager als `Rippy.exe`
|
||||||
|
(ausdrücklicher Wunsch des Commanders)
|
||||||
|
- [x] Desktop-Symbol und Startmenü-Eintrag, im Setup abschaltbar
|
||||||
|
- [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`,
|
||||||
|
`tools/beschaffen.py`) — Rippy findet, holt und aktualisiert
|
||||||
|
HandBrake/MakeMKV selbst
|
||||||
|
- [x] **Das Setup richtet die Werkzeuge wirklich ein** (`tools/einrichten.py`,
|
||||||
|
Entscheid 5) — HandBrakeCLI liegt im Paket (GPL-2), MakeMKV wird beim
|
||||||
|
Einrichten vom Hersteller geholt. Ein Setup, dem ein Pflichtwerkzeug
|
||||||
|
fehlt, meldet das im Klartext statt Erfolg.
|
||||||
|
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
|
||||||
|
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
|
||||||
|
ein leeres Blatt
|
||||||
|
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
|
||||||
|
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
|
||||||
|
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
|
||||||
|
Doppelklick stillschweigend und öffnete das Fenster.
|
||||||
|
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
|
||||||
|
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
|
||||||
|
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
|
||||||
|
behält alles.
|
||||||
|
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
|
||||||
|
— **offen**
|
||||||
|
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
||||||
|
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
|
||||||
|
**Noch offen:** ein echter Rip auf Windows ist ungeprüft — das Laufwerk
|
||||||
|
hängt an der VM.
|
||||||
|
|
||||||
|
> **Abweichung vom Plan, bewusst und mit Folgen.** Statt Nuitka `--standalone`
|
||||||
|
> (one-dir) + Inno Setup wurde **PyInstaller onefile** genommen: ein Programm,
|
||||||
|
> drei Betriebsarten (`RippySetup.exe`, `--dienst`, `--deinstallieren`), kein
|
||||||
|
> zweites Werkzeug in der Kette.
|
||||||
|
>
|
||||||
|
> Der Preis stand oben schon im Plan — *„ein Onefile-Paket entpackt sich bei
|
||||||
|
> jedem Start neu nach `%TEMP%`"* — und er ist am 28.08.2026 fällig geworden:
|
||||||
|
> Ein Dienst lief eine Stunde, `/api/health` gab 200, `/` gab **404**. Vom
|
||||||
|
> Entpack-Verzeichnis des laufenden Prozesses waren 31 statt 44 Einträge übrig,
|
||||||
|
> `ui` und `api` fehlten. Die API meldete sich gesund, während die Oberfläche
|
||||||
|
> weg war.
|
||||||
|
>
|
||||||
|
> **Gegenmaßnahme:** Der Installer legt die Oberfläche als Kopie NEBEN das
|
||||||
|
> Programm (`windows_app.ui_auspacken`), und `daemon._ui_pfad()` nimmt diese
|
||||||
|
> Kopie vor dem Entpack-Verzeichnis. Zwei Tests halten das fest. Sollte sich
|
||||||
|
> derselbe Ausfall an den API-Modulen zeigen, ist one-dir die richtige Antwort
|
||||||
|
> — dann ist diese Abweichung zurückzunehmen.
|
||||||
|
|
||||||
|
### V2-5: Docker neu
|
||||||
|
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
||||||
|
- [ ] GPU-Durchreichung: `/dev/dri` für QSV/VAAPI, `runtime: nvidia` für NVENC
|
||||||
|
- [ ] **Host-Mounts statt Container-Mounts** (Entscheid 28.08.2026)
|
||||||
|
- [ ] Multi-Arch; ARM64 ist Encode-/API-Knoten (MakeMKV hat kein ARM64-Binary)
|
||||||
|
- **Fertig, wenn:** All-in-One und verteilt laufen und `SYS_ADMIN`,
|
||||||
|
`DAC_READ_SEARCH`, `apparmor:unconfined` weg sind.
|
||||||
|
|
||||||
|
### V2-6: CLI & Pakete
|
||||||
|
- [ ] `rippy status / drives / scan / rip / queue / logs --follow / doctor`
|
||||||
|
- [ ] `rippy doctor` = die Prüfphase aus `install.sh` als Befehl (erst alles
|
||||||
|
prüfen, dann berichten, nichts ändern)
|
||||||
|
- [ ] systemd-Unit mit `SupplementaryGroups=cdrom` und `DeviceAllow` für
|
||||||
|
block-sr UND char-sg (ohne sg findet MakeMKV kein Laufwerk)
|
||||||
|
- [ ] udev-Regel für echte Disc-Ereignisse; ioctl-Poll bleibt Rückfallebene
|
||||||
|
- [ ] `.deb` / AppImage
|
||||||
|
- **Fertig, wenn:** ein Headless-Server ohne Browser bedienbar ist.
|
||||||
|
|
||||||
|
### V2-7: Neue Features
|
||||||
|
- [ ] Multi-Drive Parallel-Ripping (Rip parallel, **Schlüssel-Phase
|
||||||
|
serialisiert** — vorher an zwei Laufwerken MESSEN, nicht annehmen)
|
||||||
|
- [ ] Zero-Click gegen Interaktiv, je Laufwerk und Disc-Typ
|
||||||
|
- [ ] Auto-Presets nach gemessener Hardware (Vorschlag, kein Zwang)
|
||||||
|
- [ ] Ton-/Untertitel-Regelwerk (Originalton, Wunschsprachen, erzwungene
|
||||||
|
Untertitel behalten, Kommentarspuren verwerfen, HD-Ton durchreichen)
|
||||||
|
- [ ] **Schlüsselkette in drei Stufen** (Entscheid 28.08.2026, `KONZEPT.md` § 10)
|
||||||
|
- [ ] Anime über AniList zusätzlich zu Jikan
|
||||||
|
- [ ] Medienserver-Refresh als Auftrag MIT Wiederholung (heute still scheiternd)
|
||||||
|
- [ ] FFmpeg-Direktpfad für reines Remuxen
|
||||||
|
- **Fertig, wenn:** je Feature ein Nachweis vorliegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Ideen-Katalog (Rest) — bewusst offen
|
## 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
|
||||||
|
|||||||
+1307
-1
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
"""Pytest-Setup für das ganze Repo: das gemeinsame Paket `rippy` findbar machen.
|
||||||
|
|
||||||
|
Die Ampel ruft `pytest -q` in der Repo-Wurzel auf (.gitea/workflows/ci.yml).
|
||||||
|
Ohne diesen Eintrag scheitert JEDER Import von `rippy.*` — das Paket liegt
|
||||||
|
unter `src/`, und `src/` steht in keinem Standardpfad.
|
||||||
|
|
||||||
|
Warum `src/` und nicht `rippy/` direkt in der Wurzel: Sonst würde `pytest`
|
||||||
|
beim Einsammeln in dasselbe Verzeichnis schauen, in dem auch `docker/` liegt,
|
||||||
|
und ein zufällig gleichnamiges Modul könnte gewinnen. Ein eigenes Quellen-
|
||||||
|
Verzeichnis macht die Grenze eindeutig.
|
||||||
|
|
||||||
|
Die beiden conftest.py unter docker/api und docker/worker bleiben bestehen —
|
||||||
|
sie machen die dortigen FLACHEN Modul-Importe möglich (`import ripping`,
|
||||||
|
`import db`). Beide Mechanismen greifen nebeneinander.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_QUELLEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
||||||
|
if _QUELLEN not in sys.path:
|
||||||
|
sys.path.insert(0, _QUELLEN)
|
||||||
@@ -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"
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 31 KiB |
+24
-12
@@ -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
|
||||||
|
|||||||
Vendored
+98
-18
@@ -1,42 +1,122 @@
|
|||||||
"""Cache module."""
|
"""Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt.
|
||||||
|
|
||||||
|
## Warum das nicht mehr scheitern darf (Befund 28.08.2026)
|
||||||
|
|
||||||
|
Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis`
|
||||||
|
— der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem
|
||||||
|
Windows-PC gibt es kein Redis, und jeder Aufruf endete mit:
|
||||||
|
|
||||||
|
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
|
||||||
|
|
||||||
|
Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache:
|
||||||
|
Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an.
|
||||||
|
|
||||||
|
**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist
|
||||||
|
keiner da, wird eben jedes Mal neu gefragt — TMDB und OMDb halten das aus.
|
||||||
|
Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als
|
||||||
|
wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
## Warum trotzdem einmal gemeldet wird
|
||||||
|
|
||||||
|
Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare
|
||||||
|
Verlangsamung — und auf der VM wäre ein weggefallenes Redis ein echter
|
||||||
|
Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene
|
||||||
|
Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre
|
||||||
|
Lärm.
|
||||||
|
"""
|
||||||
|
|
||||||
from redis import Redis
|
|
||||||
from typing import Any, Optional
|
|
||||||
import json
|
import 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"),
|
|
||||||
}
|
|
||||||
+42
-5
@@ -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)}
|
||||||
|
|||||||
+1108
-105
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
|
|||||||
+180
-55
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
+137
-15
@@ -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,79 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
+124
-13
@@ -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"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+81
-18
@@ -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]}"
|
|
||||||
)
|
|
||||||
+348
-129
@@ -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 "")
|
||||||
@@ -902,7 +1117,11 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
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).
|
||||||
|
|
||||||
|
Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach
|
||||||
|
Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3) —
|
||||||
|
diese Fassung rippt CDs nur noch im Container, mit abcde."""
|
||||||
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:
|
||||||
|
|||||||
+60
-952
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""rippy — der gemeinsame Kern von API und Worker.
|
||||||
|
|
||||||
|
## Warum es dieses Paket gibt (Etappe V2-0, 28.08.2026)
|
||||||
|
|
||||||
|
Bis hierher gab es KEIN gemeinsames Paket zwischen den Containern. Drei Module
|
||||||
|
lagen deshalb zweimal im Repo, byte-identisch, und `db.py` sagte es sogar im
|
||||||
|
Kopfkommentar: „Wer die Struktur ändert, ändert BEIDE Dateien."
|
||||||
|
|
||||||
|
Ein Wächter-Test (`test_zwillinge_sind_byteweise_identisch`) hat das mechanisch
|
||||||
|
abgesichert — er war nötig, weil die Konstruktion selbst falsch war. Mit diesem
|
||||||
|
Paket entfällt beides: es gibt jede Datei genau einmal.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
Die Unterpakete folgen den Fachschichten aus KONZEPT-V2.md § 2.1:
|
||||||
|
|
||||||
|
core/ Job-Modell, Zustandsmaschine, Benachrichtigungen
|
||||||
|
drives/ Laufwerke: Erkennung, Disc-Status, Auswurf (ioctl / Win32)
|
||||||
|
rip/ MakeMKV, abcde — Kommandobau und Datenverzeichnis
|
||||||
|
|
||||||
|
Weitere Schichten (metadata, transcode, library, storage) kommen mit den
|
||||||
|
Etappen V2-1 ff. dazu. Bis dahin leben sie unverändert in docker/api bzw.
|
||||||
|
docker/worker weiter — V2-0 ändert bewusst KEIN Verhalten.
|
||||||
|
|
||||||
|
## Wo das Paket zur Laufzeit liegt
|
||||||
|
|
||||||
|
Repo src/rippy/ (auf sys.path über conftest.py in der Wurzel)
|
||||||
|
API-Image /app/rippy/ (COPY im Dockerfile, /app ist Arbeitsverzeichnis)
|
||||||
|
Worker-Image /app/rippy/ (dito)
|
||||||
|
Windows <Installationsordner>\\rippy\\ (im Zip von /worker-setup/paket)
|
||||||
|
|
||||||
|
Wer hier eine Datei ergänzt, prüft alle vier Wege — der Windows-Weg ist der,
|
||||||
|
den man am leichtesten vergisst (das Zip nimmt nicht automatisch alles mit,
|
||||||
|
siehe `worker_setup_paket` in docker/api/main.py).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""In welchem Betrieb 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 einziger dieser Sätze 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. Das macht ja aber keinen sinn da auf Windows andere dinge relevant
|
||||||
|
> sind als für den Windows Client. Das gilt für die ganze standalone version
|
||||||
|
> für Windows, auch für die settings und die Anleitung usw."
|
||||||
|
|
||||||
|
## Warum das kein Anzeigefehler ist
|
||||||
|
|
||||||
|
Die naheliegende Reparatur wäre, an dreißig Stellen `if (windows)` einzubauen.
|
||||||
|
Das wäre dieselbe Falle noch einmal: Die Oberfläche wüsste weiterhin nichts
|
||||||
|
über ihren Betrieb, sie hätte nur eine zweite Sorte Vermutung.
|
||||||
|
|
||||||
|
Die Ursache liegt tiefer: **Die Oberfläche hat nie erfahren, worauf sie
|
||||||
|
läuft.** `config.py` kennt das Profil seit V2-2, die API hat es nie
|
||||||
|
weitergegeben. Also hat das UI angenommen — und Annahmen sind in diesem
|
||||||
|
Projekt schon oft genug teuer geworden.
|
||||||
|
|
||||||
|
## Warum FÄHIGKEITEN und nicht ein Modus-Name
|
||||||
|
|
||||||
|
Ein `modus == "standalone"` würde die Oberfläche zwingen, aus einem Namen auf
|
||||||
|
Verhalten zu schließen — genau die Sorte Ableitung, die bei jedem neuen
|
||||||
|
Betriebsfall bricht. Der Kopflos-Betrieb (V2-6) hat keinen Container, aber
|
||||||
|
sehr wohl externe Worker; ein Docker-All-in-One hat einen Container, aber
|
||||||
|
keinen zweiten Worker.
|
||||||
|
|
||||||
|
Deshalb sagt Rippy, **was geht**, nicht **wie es heißt**:
|
||||||
|
|
||||||
|
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
|
||||||
|
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
|
||||||
|
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
|
||||||
|
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
|
||||||
|
|
||||||
|
Der Modus-Name bleibt trotzdem dabei — für Fehlerberichte und Überschriften.
|
||||||
|
Aber gerechnet wird mit den Fähigkeiten.
|
||||||
|
|
||||||
|
## Was hier GEMESSEN und was gelesen wird
|
||||||
|
|
||||||
|
Wo es geht, wird nachgesehen statt geglaubt:
|
||||||
|
|
||||||
|
im_container `/.dockerenv` existiert (Docker legt sie an)
|
||||||
|
plattform `sys.platform`
|
||||||
|
externe_worker Queue-Treiber `celery` (ohne Broker gibt es keine)
|
||||||
|
|
||||||
|
`profil` aus der Konfiguration ist der Rückfall, nicht die erste Quelle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
# Docker legt diese Datei in jedem Container an. Sie ist das verlässlichste
|
||||||
|
# Kennzeichen, das ohne Zusatzpakete zu haben ist — verlässlicher als eine
|
||||||
|
# Umgebungsvariable, die jeder setzen (und vergessen) kann.
|
||||||
|
DOCKER_KENNZEICHEN = "/.dockerenv"
|
||||||
|
|
||||||
|
|
||||||
|
def im_container(pruefen=None) -> bool:
|
||||||
|
"""Läuft dieser Prozess in einem Container? (gemessen, nicht geraten)"""
|
||||||
|
pruefen = pruefen or os.path.exists
|
||||||
|
return bool(pruefen(DOCKER_KENNZEICHEN))
|
||||||
|
|
||||||
|
|
||||||
|
def plattform(name: str = None) -> str:
|
||||||
|
"""`windows`, `linux` oder `macos` — nie der rohe sys.platform-String."""
|
||||||
|
roh = (name if name is not None else sys.platform).lower()
|
||||||
|
if roh.startswith("win"):
|
||||||
|
return "windows"
|
||||||
|
if roh == "darwin":
|
||||||
|
return "macos"
|
||||||
|
return "linux"
|
||||||
|
|
||||||
|
|
||||||
|
def modus(werte: dict, container: bool = None) -> str:
|
||||||
|
"""Der Name des Betriebs — für Überschriften und Fehlerberichte.
|
||||||
|
|
||||||
|
`standalone` Ein Prozess macht alles (Windows-App, Docker-All-in-One)
|
||||||
|
`verteilt` Mehrere Maschinen teilen sich die Arbeit (Celery/Redis)
|
||||||
|
"""
|
||||||
|
queue = (werte or {}).get("queue", {}) or {}
|
||||||
|
if queue.get("treiber") == "celery":
|
||||||
|
return "verteilt"
|
||||||
|
profil = (werte or {}).get("profil", "standalone")
|
||||||
|
return "verteilt" if profil in ("api", "node") else "standalone"
|
||||||
|
|
||||||
|
|
||||||
|
def auskunft(werte: dict, container: bool = None,
|
||||||
|
plattform_name: str = None) -> dict:
|
||||||
|
"""Was ist dieser Betrieb, und was kann er? — die Antwort für die API.
|
||||||
|
|
||||||
|
Reine Funktion: `container` und `plattform_name` sind einspritzbar, damit
|
||||||
|
jeder Betriebsfall prüfbar ist, ohne ihn herzustellen.
|
||||||
|
"""
|
||||||
|
werte = werte or {}
|
||||||
|
container = im_container() if container is None else container
|
||||||
|
system = plattform(plattform_name)
|
||||||
|
art = modus(werte, container)
|
||||||
|
verteilt = art == "verteilt"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"modus": art,
|
||||||
|
"plattform": system,
|
||||||
|
"im_container": container,
|
||||||
|
"kann": {
|
||||||
|
# Ohne Celery gibt es niemanden, der von außen Jobs übernimmt.
|
||||||
|
# Die Worker-Liste, das Worker-Setup und jede Rede von
|
||||||
|
# „Kein Worker antwortet" gehören dann nicht auf den Bildschirm.
|
||||||
|
"externe_worker": verteilt,
|
||||||
|
# Einhängen macht seit dem Entscheid vom 28.08.2026 der HOST
|
||||||
|
# (KONZEPT-V2.md § 4.3) — im Container zeigt Rippy nur noch die
|
||||||
|
# Zeile zum Kopieren. Unter Windows hängt man gar nichts ein:
|
||||||
|
# Dort gibt man einen UNC-Pfad an, fertig.
|
||||||
|
"freigaben_einhaengen": container,
|
||||||
|
# `/app/media` ist ein Pfad IM Container. Auf Windows heißt der
|
||||||
|
# Ordner anders und liegt woanders.
|
||||||
|
"container_pfade": container,
|
||||||
|
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
|
||||||
|
# native Installation kann ihre Werkzeuge selbst pflegen.
|
||||||
|
"werkzeuge_verwalten": not container,
|
||||||
|
# Darf die Oberfläche außerhalb der Medien-Wurzel blättern?
|
||||||
|
"frei_blaettern": frei_blaettern(werte, container),
|
||||||
|
},
|
||||||
|
# Was in der Oberfläche als Vorgabe stehen soll. Ein Feld, das mit
|
||||||
|
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
|
||||||
|
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
|
||||||
|
"arbeits_vorgabe": arbeits_vorgabe(werte, container, system),
|
||||||
|
"hilfe_befehl": "docker compose -p rippy ps" if container else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def frei_blaettern(werte: dict, container: bool = None) -> bool:
|
||||||
|
"""Darf die Oberfläche das ganze Dateisystem sehen? (Befund 28.08.2026)
|
||||||
|
|
||||||
|
## Warum diese Frage überhaupt gestellt werden muss
|
||||||
|
|
||||||
|
`MEDIA_ROOT = "/app/media"` war in `main.py` zugleich Vorgabe UND
|
||||||
|
Pfadgrenze: `/browse`, `/storage-targets` und jede Datei-Auslieferung
|
||||||
|
prüften `unter_wurzel(pfad, MEDIA_ROOT)`.
|
||||||
|
|
||||||
|
Im Container ist das genau richtig. Die API hängt dort im Netz, und eine
|
||||||
|
Weboberfläche, die jeden Pfad des Wirts ausliefern kann, ist ein Loch.
|
||||||
|
|
||||||
|
Auf dem Windows-PC des Commanders war dieselbe Zeile gleich doppelt
|
||||||
|
falsch: Den Ordner `/app/media` gibt es nicht (also war die Liste der
|
||||||
|
Arbeitsverzeichnisse LEER — sein Befund „momentan geht das nicht"), und
|
||||||
|
sein Ziel ist eine UNC-Freigabe, die unter gar keiner lokalen Wurzel
|
||||||
|
liegt.
|
||||||
|
|
||||||
|
Die Unterscheidung ist nicht „Windows" — sie ist **wer hört zu**. Ein
|
||||||
|
Container und der Kopflos-Betrieb bedienen ein Netz; die native App
|
||||||
|
bedient den Menschen, der vor dem Rechner sitzt und dessen eigene Ordner
|
||||||
|
das sind. Deshalb: kein Container UND kein verteilter Betrieb.
|
||||||
|
"""
|
||||||
|
container = im_container() if container is None else container
|
||||||
|
return not container and modus(werte, container) == "standalone"
|
||||||
|
|
||||||
|
|
||||||
|
def medien_wurzel(werte: dict, container: bool = None,
|
||||||
|
plattform_name: str = None) -> str:
|
||||||
|
"""Die Wurzel, unter der Rippy ablegt — im Container `/app/media`.
|
||||||
|
|
||||||
|
Eigener Name statt `ablage_vorgabe`, weil die Rolle eine andere ist: Die
|
||||||
|
Vorgabe füllt ein Eingabefeld, die Wurzel begrenzt Pfade. Dass beide
|
||||||
|
denselben Ort meinen, ist Absicht und kein Zufall.
|
||||||
|
"""
|
||||||
|
return ablage_vorgabe(werte, im_container() if container is None else container,
|
||||||
|
plattform(plattform_name))
|
||||||
|
|
||||||
|
|
||||||
|
def arbeits_vorgabe(werte: dict, container: bool = None,
|
||||||
|
plattform_name: str = None) -> str:
|
||||||
|
"""Wohin die Rohdaten wandern, wenn niemand etwas anderes wählt.
|
||||||
|
|
||||||
|
Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß. Wo der landet, ist keine
|
||||||
|
Nebensache — am 25.07.2026 lief damit die Container-Platte voll.
|
||||||
|
"""
|
||||||
|
werte = werte or {}
|
||||||
|
container = im_container() if container is None else container
|
||||||
|
system = plattform(plattform_name)
|
||||||
|
eigen = (werte.get("storage", {}) or {}).get("temp")
|
||||||
|
if eigen:
|
||||||
|
return eigen
|
||||||
|
if container:
|
||||||
|
return "/app/temp"
|
||||||
|
return pfade.verbinden(ablage_vorgabe(werte, container, system), "_arbeit")
|
||||||
|
|
||||||
|
|
||||||
|
def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
||||||
|
"""Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
|
||||||
|
return pfade.naechster_vorhandener(pfad, existiert)
|
||||||
|
|
||||||
|
|
||||||
|
def platz_orte(werte: dict, container: bool = None,
|
||||||
|
plattform_name: str = None) -> list:
|
||||||
|
"""Wo Platz gemessen werden soll — je Betrieb andere Orte.
|
||||||
|
|
||||||
|
## Warum das nicht fest verdrahtet sein darf (28.08.2026)
|
||||||
|
|
||||||
|
In `main.py` stand:
|
||||||
|
|
||||||
|
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
|
||||||
|
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
|
||||||
|
|
||||||
|
Beide Pfade gibt es unter Windows nicht. `shutil.disk_usage` warf, die
|
||||||
|
Liste blieb leer, und im Windows-Fenster stand **„Platz für Rippy:
|
||||||
|
unbekannt"** — obwohl auf dem Laufwerk natürlich Platz war. Eine
|
||||||
|
Nichtauskunft, die wie eine Auskunft aussieht.
|
||||||
|
|
||||||
|
Gibt eine Liste von `{"name": …, "pfad": …}` zurück; der Aufrufer misst.
|
||||||
|
"""
|
||||||
|
werte = werte or {}
|
||||||
|
container = im_container() if container is None else container
|
||||||
|
system = plattform(plattform_name)
|
||||||
|
|
||||||
|
if container:
|
||||||
|
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
|
||||||
|
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
|
||||||
|
|
||||||
|
ablage = ablage_vorgabe(werte, container, system)
|
||||||
|
temp = arbeits_vorgabe(werte, container, system)
|
||||||
|
orte = [{"name": "Ablage", "pfad": ablage}]
|
||||||
|
# ## Warum beide Orte IMMER dastehen (Commander 29.08.2026)
|
||||||
|
#
|
||||||
|
# > „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der
|
||||||
|
# > Ablagepfad sein."
|
||||||
|
#
|
||||||
|
# Hier stand vorher: zweite Zeile nur, wenn es ein ANDERES Laufwerk ist —
|
||||||
|
# mit der Begründung, zweimal dieselbe Zahl unter zwei Namen sehe aus wie
|
||||||
|
# zwei Auskünfte und sei eine. Das stimmt für die ZAHL und war der falsche
|
||||||
|
# Schluss für den ORT: Auf seinem Bildschirm stand nur „Platz für Rippy:
|
||||||
|
# 59,9 von 232 GB frei" — welche Ordner das sind, war nicht zu sehen.
|
||||||
|
#
|
||||||
|
# Beides gehört hin, und beides mit Pfad. Dass die Zahl dieselbe ist,
|
||||||
|
# steht jetzt ausdrücklich dabei (`gleiches_laufwerk`), statt die Zeile
|
||||||
|
# wegzulassen.
|
||||||
|
gleich = pfade.gleiches_laufwerk(temp, ablage)
|
||||||
|
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp,
|
||||||
|
"gleiches_laufwerk": gleich})
|
||||||
|
orte[0]["gleiches_laufwerk"] = gleich
|
||||||
|
return orte
|
||||||
|
|
||||||
|
|
||||||
|
def mit_einstellungen(werte: dict, einstellungen: dict) -> dict:
|
||||||
|
r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen.
|
||||||
|
|
||||||
|
## Warum es diese Bruecke braucht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Commander: „die verzeichnise sind andere als dort steht."
|
||||||
|
|
||||||
|
Rippy hat ZWEI Speicher fuer dieselbe Frage:
|
||||||
|
|
||||||
|
Oberflaeche -> Datenbank, Schluessel `outputDir` und `workDir`
|
||||||
|
Betrieb -> Konfigurationsdatei, `storage.medien` / `storage.temp`
|
||||||
|
|
||||||
|
Geschrieben wird nur der erste — die Einstellungsseite kennt die
|
||||||
|
Konfigurationsdatei gar nicht. `ablage_vorgabe` und `arbeits_vorgabe`
|
||||||
|
lesen aber den zweiten, und der ist leer. Sie fielen deshalb IMMER auf
|
||||||
|
die Vorgabe zurueck: In „Einstellungen -> System" standen dauerhaft
|
||||||
|
`…\Videos\Rippy` und `…\Videos\Rippy\_arbeit`, egal was
|
||||||
|
eingestellt war — waehrend der Rip in Wahrheit nach `F:\` lief.
|
||||||
|
|
||||||
|
Der Worker macht es richtig herum: `_arbeitsverzeichnis()` liest
|
||||||
|
`workDir` aus der Datenbank und faellt erst DANN auf die Vorgabe
|
||||||
|
zurueck. Diese Funktion stellt dieselbe Reihenfolge fuer alle her, die
|
||||||
|
ueber `betrieb` fragen — statt sie ein drittes Mal nachzubauen.
|
||||||
|
|
||||||
|
Pure Funktion: Sie nimmt beide Woerterbuecher und gibt ein neues zurueck.
|
||||||
|
"""
|
||||||
|
lager = dict(((werte or {}).get("storage") or {}))
|
||||||
|
ablage = ((einstellungen or {}).get("outputDir") or "").strip()
|
||||||
|
arbeit = ((einstellungen or {}).get("workDir") or "").strip()
|
||||||
|
if ablage:
|
||||||
|
lager["medien"] = ablage
|
||||||
|
if arbeit:
|
||||||
|
lager["temp"] = arbeit
|
||||||
|
zusammen = dict(werte or {})
|
||||||
|
zusammen["storage"] = lager
|
||||||
|
return zusammen
|
||||||
|
|
||||||
|
|
||||||
|
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
||||||
|
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
||||||
|
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
||||||
|
if eigen:
|
||||||
|
return eigen
|
||||||
|
if container:
|
||||||
|
return "/app/media"
|
||||||
|
# Der Windows-Sonderzweig ist mit der Standalone-App gegangen (v5 ist
|
||||||
|
# ein eigenes Produkt, KONZEPT-WINDOWS.md Entscheid 7) — nativ heisst
|
||||||
|
# hier nur noch: ein Entwickler-Lauf ausserhalb des Containers.
|
||||||
|
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Ereignis-Bus: Nachrichten über Änderungen — nie der Zustand selbst.
|
||||||
|
|
||||||
|
## Die eine Regel, aus der alles folgt
|
||||||
|
|
||||||
|
Der Bus überträgt nur NACHRICHTEN ÜBER ÄNDERUNGEN.
|
||||||
|
Wer den Zustand will, fragt den Store.
|
||||||
|
|
||||||
|
Das klingt nach Prinzipienreiterei und ist die Lehre aus dem teuersten
|
||||||
|
UI-Fehler von v1. Dort stand fünfmal `catch(() => [])`: Jeder fehlgeschlagene
|
||||||
|
Abruf hieß damit „es gibt keine Jobs, keine Laufwerke, keine Ablagen". Die
|
||||||
|
Liste leerte sich für einen Takt und füllte sich vier Sekunden später wieder.
|
||||||
|
Der Commander meldete das als „wird oft neu geladen", und die Ursache war
|
||||||
|
unsichtbar, weil der Fehlerzweig nichts protokollierte (AGENTS.md: „Ein
|
||||||
|
verpasster Abruf ist keine Nachricht über die Welt").
|
||||||
|
|
||||||
|
Wenn der Bus keinen Zustand trägt, kann ein verpasstes Ereignis auch keinen
|
||||||
|
Zustand löschen. Ein Abriss heißt dann: „Ich weiß gerade nichts Neues" — nicht
|
||||||
|
„es gibt nichts".
|
||||||
|
|
||||||
|
## Drei Eigenschaften, die daraus folgen
|
||||||
|
|
||||||
|
1. **`snapshot` zuerst.** Wer sich verbindet, bekommt als Erstes den
|
||||||
|
vollständigen Zustand, danach nur noch Deltas. Ein Client sieht nie ein
|
||||||
|
halbes Bild.
|
||||||
|
2. **`seq` ist monoton und lückenlos.** Ein Reconnect mit `Last-Event-ID`
|
||||||
|
liefert alles Verpasste nach.
|
||||||
|
3. **Eine zu große Lücke wird ANGESAGT.** Ist das Verpasste aus dem Ringpuffer
|
||||||
|
gefallen, schickt der Bus ausdrücklich einen neuen `snapshot` statt
|
||||||
|
stillschweigend bei den neuesten Deltas weiterzumachen. Stillschweigend
|
||||||
|
wäre der schlimmere Fall: Das UI hielte sich für aktuell und wäre es nicht.
|
||||||
|
|
||||||
|
## Treiber
|
||||||
|
|
||||||
|
memory.py asyncio-Warteschlangen + Ringpuffer — Standalone-Betrieb
|
||||||
|
redis.py Redis Pub/Sub, Ringpuffer in der Tabelle `ereignisse` (V2-5)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rippy.bus.schema import ( # noqa: F401
|
||||||
|
EREIGNIS_TYPEN,
|
||||||
|
baue_ereignis,
|
||||||
|
ist_gueltig,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["EREIGNIS_TYPEN", "baue_ereignis", "ist_gueltig"]
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""In-Process-Bus: asyncio-Warteschlangen plus Ringpuffer.
|
||||||
|
|
||||||
|
Erfüllt `rippy.ports.Bus`. Der Treiber für den Standalone-Betrieb — API,
|
||||||
|
Queue und UI leben dort in EINEM Prozess, es braucht also nichts dazwischen.
|
||||||
|
Der Redis-Treiber für den verteilten Betrieb kommt in V2-5 und hat dieselbe
|
||||||
|
Form.
|
||||||
|
|
||||||
|
## Warum ein Ringpuffer und nicht nur Warteschlangen
|
||||||
|
|
||||||
|
Ein Browser verliert die SSE-Verbindung ständig — beim Sperrbildschirm, beim
|
||||||
|
WLAN-Wechsel, beim Tab-Wechsel auf dem Handy. Ohne Puffer wäre jede dieser
|
||||||
|
Sekunden ein Loch: Der Fortschritt spränge von 12 % auf 40 %, ein
|
||||||
|
`job.finished` ginge verloren, und das UI zeigte einen Job als laufend, der
|
||||||
|
längst durch ist.
|
||||||
|
|
||||||
|
Mit Puffer sagt der Browser beim Wiederverbinden „ich hatte zuletzt 1042", und
|
||||||
|
bekommt 1043 ff. nachgeliefert.
|
||||||
|
|
||||||
|
## Und warum eine zu große Lücke angesagt wird
|
||||||
|
|
||||||
|
Ist das Verpasste aus dem Puffer gefallen, gibt es zwei Möglichkeiten:
|
||||||
|
stillschweigend bei den neuesten Deltas weitermachen — oder sagen, dass etwas
|
||||||
|
fehlt. Der erste Weg ist der gefährlichere: Das UI hielte sich für aktuell und
|
||||||
|
wäre es nicht, und niemand könnte den Unterschied sehen. Deshalb kommt in dem
|
||||||
|
Fall ein `snapshot`.
|
||||||
|
|
||||||
|
## Langsame Abonnenten
|
||||||
|
|
||||||
|
Jeder Abonnent hat eine eigene, BEGRENZTE Warteschlange. Läuft sie über — ein
|
||||||
|
Browser auf einem lahmen Handy, ein Tab im Hintergrund —, wird der Abonnent
|
||||||
|
markiert und bekommt beim nächsten Mal einen `snapshot` statt der verpassten
|
||||||
|
Deltas. Was NICHT passiert: dass der Sender wartet. Ein einziger langsamer
|
||||||
|
Zuhörer darf den Rip nicht bremsen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from rippy.bus.schema import RINGPUFFER, baue_ereignis
|
||||||
|
|
||||||
|
# Wie viele Ereignisse ein einzelner Abonnent aufstauen darf, bevor er als
|
||||||
|
# „abgehängt" gilt. Großzügig: Ein normaler Client leert die Schlange sofort.
|
||||||
|
ABONNENT_PUFFER = 256
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryBus:
|
||||||
|
"""Ereignisse verteilen — ohne Broker, ohne Netz."""
|
||||||
|
|
||||||
|
def __init__(self, ringpuffer: int = RINGPUFFER):
|
||||||
|
self._seq = 0
|
||||||
|
self._puffer: deque = deque(maxlen=ringpuffer)
|
||||||
|
self._abonnenten: set = set()
|
||||||
|
|
||||||
|
# ── Senden ──────────────────────────────────────────────────────────
|
||||||
|
def senden(self, typ: str, daten: dict = None, entitaet: str = None,
|
||||||
|
entitaet_id: str = None) -> dict:
|
||||||
|
"""Nimmt ein Ereignis an, nummeriert es und verteilt es.
|
||||||
|
|
||||||
|
Bewusst SYNCHRON: Gesendet wird aus Rip-Schleifen und aus DB-Callbacks,
|
||||||
|
also aus Code, der kein `await` kann. Die Zustellung ist ein
|
||||||
|
`put_nowait` je Abonnent — es wartet nie jemand.
|
||||||
|
"""
|
||||||
|
self._seq += 1
|
||||||
|
ereignis = baue_ereignis(
|
||||||
|
typ, daten=daten, entitaet=entitaet,
|
||||||
|
entitaet_id=entitaet_id, seq=self._seq,
|
||||||
|
)
|
||||||
|
self._puffer.append(ereignis)
|
||||||
|
for abo in list(self._abonnenten):
|
||||||
|
abo.zustellen(ereignis)
|
||||||
|
return ereignis
|
||||||
|
|
||||||
|
# ── Empfangen ───────────────────────────────────────────────────────
|
||||||
|
def abonnieren(self, ab_seq: int = None):
|
||||||
|
"""Neuer Abonnent. `ab_seq` = die zuletzt gesehene Folgenummer."""
|
||||||
|
abo = _Abonnent(self, ab_seq)
|
||||||
|
self._abonnenten.add(abo)
|
||||||
|
return abo
|
||||||
|
|
||||||
|
def abmelden(self, abo) -> None:
|
||||||
|
self._abonnenten.discard(abo)
|
||||||
|
|
||||||
|
# ── Nachliefern ─────────────────────────────────────────────────────
|
||||||
|
def nachliefern(self, ab_seq: int):
|
||||||
|
"""Was seit `ab_seq` passiert ist — oder None, wenn die Lücke zu groß ist.
|
||||||
|
|
||||||
|
None heißt für den Aufrufer ausdrücklich: „Ich kann die Lücke nicht
|
||||||
|
füllen, hol dir einen frischen Gesamtstand." Ein leeres Ergebnis
|
||||||
|
dagegen heißt „nichts verpasst". Die beiden zu vermischen wäre genau
|
||||||
|
der v1-Fehler in neuer Kleidung.
|
||||||
|
"""
|
||||||
|
if ab_seq is None:
|
||||||
|
return []
|
||||||
|
if not self._puffer:
|
||||||
|
# Nichts im Puffer: Wenn der Client auf dem Stand ist, ist das
|
||||||
|
# in Ordnung; liegt er zurück, fehlt ihm etwas.
|
||||||
|
return [] if ab_seq >= self._seq else None
|
||||||
|
aeltestes = self._puffer[0]["seq"]
|
||||||
|
if ab_seq + 1 < aeltestes:
|
||||||
|
return None # aus dem Puffer gefallen
|
||||||
|
return [e for e in self._puffer if e["seq"] > ab_seq]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seq(self) -> int:
|
||||||
|
return self._seq
|
||||||
|
|
||||||
|
|
||||||
|
class _Abonnent:
|
||||||
|
"""Eine Warteschlange plus die Merkposten für einen Zuhörer."""
|
||||||
|
|
||||||
|
def __init__(self, bus: MemoryBus, ab_seq: int = None):
|
||||||
|
self._bus = bus
|
||||||
|
self._queue: asyncio.Queue = asyncio.Queue(maxsize=ABONNENT_PUFFER)
|
||||||
|
self.ab_seq = ab_seq
|
||||||
|
self.abgehaengt = False # Puffer lief über -> braucht snapshot
|
||||||
|
|
||||||
|
def zustellen(self, ereignis: dict) -> None:
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(ereignis)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# Nicht warten, nicht das älteste stillschweigend wegwerfen:
|
||||||
|
# merken, dass dieser Zuhörer den Anschluss verloren hat.
|
||||||
|
self.abgehaengt = True
|
||||||
|
|
||||||
|
async def naechstes(self, timeout: float = None):
|
||||||
|
"""Nächstes Ereignis, oder None bei Zeitüberschreitung.
|
||||||
|
|
||||||
|
Das Timeout ist der Herzschlag: Eine SSE-Verbindung, über die minutenlang
|
||||||
|
nichts geht, wird von manchen Proxys geschlossen. Der Aufrufer schickt
|
||||||
|
dann einen Kommentar-Frame.
|
||||||
|
"""
|
||||||
|
if timeout is None:
|
||||||
|
return await self._queue.get()
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(self._queue.get(), timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def schliessen(self) -> None:
|
||||||
|
self._bus.abmelden(self)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
self.schliessen()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Der Bus des Prozesses. Ein Modul-Singleton, weil ihn API, Queue und
|
||||||
|
# Rip-Schleife gemeinsam benutzen und niemand ihn herumreichen soll.
|
||||||
|
bus = MemoryBus()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Das Ereignis-Schema — eine Form für alle Nachrichten (KONZEPT-V2.md § 6.3).
|
||||||
|
|
||||||
|
Ein Ereignis sieht IMMER so aus:
|
||||||
|
|
||||||
|
{
|
||||||
|
"seq": 1043, # monoton, lückenlos
|
||||||
|
"ts": "2026-08-28T10:14:22Z",
|
||||||
|
"typ": "job.progress",
|
||||||
|
"entitaet": "job",
|
||||||
|
"entitaet_id":"a4f…",
|
||||||
|
"daten": {"phase": "rip", "prozent": 37, "eta_sekunden": 1820}
|
||||||
|
}
|
||||||
|
|
||||||
|
## Warum die Typen hier als Liste stehen
|
||||||
|
|
||||||
|
Damit ein Tippfehler auffällt. Ein Ereignis mit `typ="job.progres"` würde sonst
|
||||||
|
gesendet, käme im UI an und träfe dort auf keinen einzigen Empfänger — ohne
|
||||||
|
Fehlermeldung, ohne Log, ohne Hinweis. Diese Liste macht daraus einen
|
||||||
|
Programmfehler, der beim Bauen auffällt statt im Betrieb.
|
||||||
|
|
||||||
|
Wer einen Typ ergänzt, trägt ihn HIER ein. Der Test dazu prüft, dass jeder
|
||||||
|
gesendete Typ bekannt ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Alle Ereignistypen, die es gibt. Kommentar = wann sie kommen.
|
||||||
|
EREIGNIS_TYPEN = {
|
||||||
|
# Zustand am Anfang jeder Verbindung — und nach einer zu großen Lücke.
|
||||||
|
"snapshot": "Vollständiger Zustand: Jobs, Laufwerke, Knoten, Mounts",
|
||||||
|
|
||||||
|
"drive.changed": "Laufwerks-Zustand hat sich geändert",
|
||||||
|
"disc.inserted": "Disc eingelegt",
|
||||||
|
"disc.removed": "Disc entnommen",
|
||||||
|
|
||||||
|
"job.created": "Neuer Job angelegt",
|
||||||
|
"job.phase": "Job wechselt die Phase (scan/rip/transcode/ablegen)",
|
||||||
|
"job.progress": "Fortschritt — gedrosselt auf höchstens 1/s",
|
||||||
|
"job.finished": "Job fertig, fehlgeschlagen oder abgebrochen",
|
||||||
|
|
||||||
|
"log.line": "Eine Log-Zeile",
|
||||||
|
|
||||||
|
"node.seen": "Ein Knoten hat sich gemeldet",
|
||||||
|
"node.lost": "Ein Knoten meldet sich nicht mehr",
|
||||||
|
|
||||||
|
"mount.changed": "Ein Speicherziel ist erreichbar geworden oder weggefallen",
|
||||||
|
|
||||||
|
"system.status": "Server-Zustand: Hardware, Worker, Ablageziele (langsamer Takt)",
|
||||||
|
"system.notice": "Hinweis an den Nutzer (Plattenplatz, Rate-Limit, …)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wie viele Ereignisse der Ringpuffer hält. Bei ~1 Fortschritts-Ereignis pro
|
||||||
|
# Sekunde sind 512 gut acht Minuten Rückschau — mehr als genug für einen
|
||||||
|
# Browser-Reconnect (der binnen Sekunden passiert) und wenig genug, um im
|
||||||
|
# Speicher nicht aufzufallen.
|
||||||
|
RINGPUFFER = 512
|
||||||
|
|
||||||
|
|
||||||
|
def ist_gueltig(typ: str) -> bool:
|
||||||
|
return typ in EREIGNIS_TYPEN
|
||||||
|
|
||||||
|
|
||||||
|
def baue_ereignis(typ: str, daten: dict = None, entitaet: str = None,
|
||||||
|
entitaet_id: str = None, seq: int = 0, ts=None) -> dict:
|
||||||
|
"""Baut ein Ereignis und prüft den Typ. Wirft bei unbekanntem Typ.
|
||||||
|
|
||||||
|
Absichtlich hart: Ein unbekannter Typ ist ein Programmfehler, kein
|
||||||
|
Betriebszustand. Ihn durchzulassen hieße, eine Nachricht zu senden, die
|
||||||
|
garantiert niemand empfängt — der stillste aller Fehlschläge.
|
||||||
|
"""
|
||||||
|
if not ist_gueltig(typ):
|
||||||
|
bekannt = ", ".join(sorted(EREIGNIS_TYPEN))
|
||||||
|
raise ValueError(
|
||||||
|
f"Unbekannter Ereignistyp {typ!r}. Bekannt sind: {bekannt}. "
|
||||||
|
"Neue Typen gehören in rippy/bus/schema.py — sonst kommt die "
|
||||||
|
"Nachricht nirgendwo an und niemand merkt es."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"seq": seq,
|
||||||
|
"ts": (ts or datetime.now(timezone.utc)).isoformat(),
|
||||||
|
"typ": typ,
|
||||||
|
"entitaet": entitaet,
|
||||||
|
"entitaet_id": entitaet_id,
|
||||||
|
"daten": daten or {},
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Der Bus muss drei Dinge können — und alle drei stammen aus v1-Fehlern.
|
||||||
|
|
||||||
|
1. **Nachliefern.** Ein Browser verliert die Verbindung ständig. Ohne
|
||||||
|
Nachlieferung springt der Fortschritt und ein `job.finished` geht verloren.
|
||||||
|
2. **Eine zu große Lücke ANSAGEN.** Stillschweigend bei den neuesten Deltas
|
||||||
|
weiterzumachen wäre der gefährlichere Weg: Das UI hielte sich für aktuell
|
||||||
|
und wäre es nicht.
|
||||||
|
3. **Einen langsamen Zuhörer nicht zum Problem aller machen.** Ein Tab im
|
||||||
|
Hintergrund darf den Rip nicht bremsen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.bus import schema
|
||||||
|
from rippy.bus.memory import ABONNENT_PUFFER, MemoryBus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bus():
|
||||||
|
return MemoryBus(ringpuffer=10)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schema ──────────────────────────────────────────────────────────────
|
||||||
|
def test_unbekannter_typ_fliegt_sofort_auf(bus):
|
||||||
|
"""Ein Tippfehler im Typ waere sonst der stillste aller Fehlschlaege:
|
||||||
|
Die Nachricht ginge raus, traefe auf keinen Empfaenger, und nirgends
|
||||||
|
stuende etwas."""
|
||||||
|
with pytest.raises(ValueError, match="Unbekannter Ereignistyp"):
|
||||||
|
bus.senden("job.progres", {"prozent": 5})
|
||||||
|
|
||||||
|
|
||||||
|
def test_bekannter_typ_geht_durch(bus):
|
||||||
|
ereignis = bus.senden("job.progress", {"prozent": 5}, entitaet="job", entitaet_id="j1")
|
||||||
|
assert ereignis["typ"] == "job.progress"
|
||||||
|
assert ereignis["entitaet_id"] == "j1"
|
||||||
|
assert ereignis["seq"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alle_typen_im_schema_sind_benutzbar(bus):
|
||||||
|
"""Wer einen Typ eintraegt, soll ihn auch senden koennen."""
|
||||||
|
for typ in schema.EREIGNIS_TYPEN:
|
||||||
|
bus.senden(typ, {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Folgenummern ────────────────────────────────────────────────────────
|
||||||
|
def test_seq_zaehlt_lueckenlos_hoch(bus):
|
||||||
|
nummern = [bus.senden("log.line", {"text": str(i)})["seq"] for i in range(5)]
|
||||||
|
assert nummern == [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nachliefern ─────────────────────────────────────────────────────────
|
||||||
|
def test_nachliefern_gibt_genau_das_verpasste(bus):
|
||||||
|
for i in range(5):
|
||||||
|
bus.senden("log.line", {"text": str(i)})
|
||||||
|
verpasst = bus.nachliefern(2)
|
||||||
|
assert [e["seq"] for e in verpasst] == [3, 4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nachliefern_ohne_rueckstand_ist_leer(bus):
|
||||||
|
for i in range(3):
|
||||||
|
bus.senden("log.line", {"text": str(i)})
|
||||||
|
assert bus.nachliefern(3) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_zu_grosse_luecke_gibt_none_nicht_leer(bus):
|
||||||
|
"""DER Unterschied, auf den es ankommt.
|
||||||
|
|
||||||
|
`[]` heisst „du hast nichts verpasst". `None` heisst „ich kann die Luecke
|
||||||
|
nicht fuellen, hol dir einen frischen Gesamtstand". Die beiden zu
|
||||||
|
vermischen waere derselbe Fehler wie `catch(() => [])` im alten UI: eine
|
||||||
|
Nichtauskunft, die als Aussage gelesen wird.
|
||||||
|
"""
|
||||||
|
for i in range(20): # Ringpuffer fasst nur 10
|
||||||
|
bus.senden("log.line", {"text": str(i)})
|
||||||
|
assert bus.nachliefern(2) is None
|
||||||
|
assert bus.nachliefern(15) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_leerer_bus_meldet_rueckstand_ehrlich():
|
||||||
|
frisch = MemoryBus(ringpuffer=10)
|
||||||
|
assert frisch.nachliefern(0) == [] # nichts passiert, nichts verpasst
|
||||||
|
assert frisch.nachliefern(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Zustellung ──────────────────────────────────────────────────────────
|
||||||
|
def test_abonnent_bekommt_was_nach_dem_abo_kommt():
|
||||||
|
async def lauf():
|
||||||
|
bus = MemoryBus()
|
||||||
|
with bus.abonnieren() as abo:
|
||||||
|
bus.senden("job.created", {"id": "j1"}, entitaet="job", entitaet_id="j1")
|
||||||
|
ereignis = await abo.naechstes(timeout=1)
|
||||||
|
return ereignis
|
||||||
|
|
||||||
|
ereignis = asyncio.run(lauf())
|
||||||
|
assert ereignis["typ"] == "job.created"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwei_abonnenten_bekommen_beide_alles():
|
||||||
|
async def lauf():
|
||||||
|
bus = MemoryBus()
|
||||||
|
with bus.abonnieren() as a, bus.abonnieren() as b:
|
||||||
|
bus.senden("disc.inserted", {"laufwerk_id": "sr0"})
|
||||||
|
return await a.naechstes(timeout=1), await b.naechstes(timeout=1)
|
||||||
|
|
||||||
|
erst, zweit = asyncio.run(lauf())
|
||||||
|
assert erst["seq"] == zweit["seq"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgemeldeter_abonnent_bekommt_nichts_mehr():
|
||||||
|
async def lauf():
|
||||||
|
bus = MemoryBus()
|
||||||
|
abo = bus.abonnieren()
|
||||||
|
abo.schliessen()
|
||||||
|
bus.senden("log.line", {"text": "danach"})
|
||||||
|
return await abo.naechstes(timeout=0.05)
|
||||||
|
|
||||||
|
assert asyncio.run(lauf()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_gibt_none_statt_zu_haengen():
|
||||||
|
"""Der Herzschlag der SSE-Verbindung haengt daran: Kommt minutenlang
|
||||||
|
nichts, muss der Aufrufer trotzdem einen Kommentar-Frame schicken
|
||||||
|
koennen, sonst schliessen manche Proxys die Verbindung."""
|
||||||
|
async def lauf():
|
||||||
|
bus = MemoryBus()
|
||||||
|
with bus.abonnieren() as abo:
|
||||||
|
return await abo.naechstes(timeout=0.05)
|
||||||
|
|
||||||
|
assert asyncio.run(lauf()) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Langsamer Zuhörer ───────────────────────────────────────────────────
|
||||||
|
def test_langsamer_abonnent_bremst_den_sender_nicht():
|
||||||
|
"""Ein Tab im Hintergrund darf den Rip nicht anhalten.
|
||||||
|
|
||||||
|
Der Sender laeuft hier ueber die Puffergrenze hinaus. Erwartet wird: Er
|
||||||
|
kommt durch, und der abgehaengte Zuhoerer ist MARKIERT — nicht, dass
|
||||||
|
Ereignisse stillschweigend verschwinden.
|
||||||
|
"""
|
||||||
|
async def lauf():
|
||||||
|
bus = MemoryBus(ringpuffer=ABONNENT_PUFFER * 2)
|
||||||
|
with bus.abonnieren() as abo:
|
||||||
|
for i in range(ABONNENT_PUFFER + 20):
|
||||||
|
bus.senden("log.line", {"text": str(i)}) # niemand liest
|
||||||
|
return abo.abgehaengt, bus.seq
|
||||||
|
|
||||||
|
abgehaengt, seq = asyncio.run(lauf())
|
||||||
|
assert abgehaengt is True
|
||||||
|
assert seq == ABONNENT_PUFFER + 20 # der Sender ist durchgelaufen
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"""Der Wächter macht Worker-Änderungen zu Ereignissen — ohne Datenbank getestet.
|
||||||
|
|
||||||
|
`unterschiede()` ist eine reine Funktion, `einmal()` bekommt Store und Bus
|
||||||
|
eingespritzt. Beides läuft deshalb auf jeder Plattform und ohne Postgres —
|
||||||
|
genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
|
||||||
|
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBus:
|
||||||
|
def __init__(self):
|
||||||
|
self.gesendet = []
|
||||||
|
|
||||||
|
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
|
||||||
|
self.gesendet.append((typ, entitaet_id, daten or {}))
|
||||||
|
return {"typ": typ}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def typen(self):
|
||||||
|
return [t for t, _, _ in self.gesendet]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self, jobs=None, logs=None):
|
||||||
|
self.jobs = jobs or []
|
||||||
|
self.logs = logs or []
|
||||||
|
self.protokoll = []
|
||||||
|
|
||||||
|
def list_jobs(self, limit=100):
|
||||||
|
return list(self.jobs)
|
||||||
|
|
||||||
|
def list_logs(self, limit=50):
|
||||||
|
return list(self.logs)
|
||||||
|
|
||||||
|
def add_log(self, level, quelle, text):
|
||||||
|
self.protokoll.append((level, quelle, text))
|
||||||
|
|
||||||
|
|
||||||
|
def _job(id_, status="pending", progress=0, title="Akira", error=None):
|
||||||
|
return {"id": id_, "status": status, "progress": progress,
|
||||||
|
"title": title, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
# ── unterschiede(): die reine Logik ─────────────────────────────────────
|
||||||
|
def test_neuer_job_ist_ein_created():
|
||||||
|
ereignisse = unterschiede({}, {"j1": {"status": "pending", "progress": 0}})
|
||||||
|
assert ereignisse == [("job.created", "j1", {"status": "pending", "progress": 0})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_stand_erzeugt_nichts():
|
||||||
|
stand = {"j1": {"status": "ripping", "progress": 10}}
|
||||||
|
assert unterschiede(stand, dict(stand)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortschritt_ist_ein_progress():
|
||||||
|
vorher = {"j1": {"status": "ripping", "progress": 10}}
|
||||||
|
jetzt = {"j1": {"status": "ripping", "progress": 11}}
|
||||||
|
typ, job_id, _ = unterschiede(vorher, jetzt)[0]
|
||||||
|
assert (typ, job_id) == ("job.progress", "j1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_statuswechsel_mitten_drin_ist_eine_phase():
|
||||||
|
vorher = {"j1": {"status": "ripping", "progress": 99}}
|
||||||
|
jetzt = {"j1": {"status": "transcoding", "progress": 0}}
|
||||||
|
typ, _, daten = unterschiede(vorher, jetzt)[0]
|
||||||
|
assert typ == "job.phase"
|
||||||
|
assert daten["von"] == "ripping" and daten["nach"] == "transcoding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_endzustand_ist_ein_finished():
|
||||||
|
for ende in ("completed", "failed", "canceled"):
|
||||||
|
vorher = {"j1": {"status": "transcoding", "progress": 80}}
|
||||||
|
jetzt = {"j1": {"status": ende, "progress": 100}}
|
||||||
|
assert unterschiede(vorher, jetzt)[0][0] == "job.finished"
|
||||||
|
|
||||||
|
|
||||||
|
def test_geloeschter_job_wird_gemeldet():
|
||||||
|
"""Sonst bliebe er im UI stehen, bis jemand neu laedt — und genau das
|
||||||
|
Neuladen soll ja verschwinden."""
|
||||||
|
typ, job_id, daten = unterschiede({"j1": {"status": "completed"}}, {})[0]
|
||||||
|
assert (typ, job_id) == ("job.finished", "j1")
|
||||||
|
assert daten["status"] == "geloescht"
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_jobs_kommen_vor_aenderungen():
|
||||||
|
"""Ein Client darf einen Job nie „geaendert" sehen, bevor er ihn kennt."""
|
||||||
|
vorher = {"alt": {"status": "ripping", "progress": 1}}
|
||||||
|
jetzt = {"alt": {"status": "ripping", "progress": 2},
|
||||||
|
"neu": {"status": "pending", "progress": 0}}
|
||||||
|
typen = [t for t, _, _ in unterschiede(vorher, jetzt)]
|
||||||
|
assert typen.index("job.created") < typen.index("job.progress")
|
||||||
|
|
||||||
|
|
||||||
|
# ── einmal(): der Durchlauf ─────────────────────────────────────────────
|
||||||
|
def test_erster_lauf_meldet_nichts():
|
||||||
|
"""Sonst bekaeme jeder API-Neustart die gesamte Job-Historie als
|
||||||
|
„gerade passiert" — und das UI zeigte hundert Meldungen auf einmal."""
|
||||||
|
bus = FakeBus()
|
||||||
|
store = FakeStore(jobs=[_job("j1", "completed", 100)],
|
||||||
|
logs=[{"id": 7, "level": "info", "source": "worker", "message": "alt"}])
|
||||||
|
w = Waechter(store, bus)
|
||||||
|
assert w.einmal() == 0
|
||||||
|
assert bus.gesendet == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweiter_lauf_meldet_die_aenderung():
|
||||||
|
bus = FakeBus()
|
||||||
|
store = FakeStore(jobs=[_job("j1", "ripping", 10)])
|
||||||
|
w = Waechter(store, bus)
|
||||||
|
w.einmal()
|
||||||
|
store.jobs = [_job("j1", "ripping", 25)]
|
||||||
|
assert w.einmal() == 1
|
||||||
|
assert bus.typen == ["job.progress"]
|
||||||
|
assert bus.gesendet[0][1] == "j1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_logzeilen_kommen_aelteste_zuerst():
|
||||||
|
"""list_logs gibt neueste zuerst. Wuerde der Waechter das durchreichen,
|
||||||
|
liefe das Log-Fenster rueckwaerts."""
|
||||||
|
bus = FakeBus()
|
||||||
|
store = FakeStore(logs=[{"id": 5, "level": "info", "source": "w", "message": "alt"}])
|
||||||
|
w = Waechter(store, bus)
|
||||||
|
w.einmal()
|
||||||
|
store.logs = [
|
||||||
|
{"id": 7, "level": "info", "source": "w", "message": "zweite"},
|
||||||
|
{"id": 6, "level": "info", "source": "w", "message": "erste"},
|
||||||
|
{"id": 5, "level": "info", "source": "w", "message": "alt"},
|
||||||
|
]
|
||||||
|
w.einmal()
|
||||||
|
texte = [d["message"] for t, _, d in bus.gesendet if t == "log.line"]
|
||||||
|
assert texte == ["erste", "zweite"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_logzeile_kommt_nur_einmal():
|
||||||
|
bus = FakeBus()
|
||||||
|
store = FakeStore(logs=[{"id": 1, "level": "info", "source": "w", "message": "a"}])
|
||||||
|
w = Waechter(store, bus)
|
||||||
|
w.einmal()
|
||||||
|
store.logs = [{"id": 2, "level": "info", "source": "w", "message": "b"},
|
||||||
|
{"id": 1, "level": "info", "source": "w", "message": "a"}]
|
||||||
|
w.einmal()
|
||||||
|
w.einmal()
|
||||||
|
assert [d["message"] for t, _, d in bus.gesendet if t == "log.line"] == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_alter_ist_abfragbar():
|
||||||
|
"""AGENTS.md: „wer einen Vorrat anlegt, macht sein Alter abfragbar".
|
||||||
|
|
||||||
|
Ein Waechter, der still gestorben ist, sieht von aussen genauso aus wie
|
||||||
|
einer, bei dem gerade nichts passiert — es sei denn, man kann fragen.
|
||||||
|
"""
|
||||||
|
w = Waechter(FakeStore(), FakeBus())
|
||||||
|
assert w.lebt_seit_sekunden() == -1.0 # noch nie gelaufen
|
||||||
|
assert w.gesund is False
|
||||||
|
w.einmal()
|
||||||
|
assert 0 <= w.lebt_seit_sekunden() < 1
|
||||||
|
assert w.gesund is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufwerke ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _lw(pfad, status="empty", typ="unknown"):
|
||||||
|
return {"path": pfad, "id": pfad.split("/")[-1], "status": status, "type": typ}
|
||||||
|
|
||||||
|
|
||||||
|
def test_eingelegte_disc_bekommt_ein_eigenes_ereignis():
|
||||||
|
"""Das UI reagiert darauf anders als auf eine bloße Zustandsaenderung —
|
||||||
|
bei einer eingelegten Disc springt der Rip-Dialog auf."""
|
||||||
|
vorher = {"/dev/sr0": _lw("/dev/sr0", "empty")}
|
||||||
|
jetzt = {"/dev/sr0": _lw("/dev/sr0", "ready", "bluray")}
|
||||||
|
typen = [t for t, _, _ in laufwerks_unterschiede(vorher, jetzt)]
|
||||||
|
assert "disc.inserted" in typen
|
||||||
|
assert typen.index("disc.inserted") < typen.index("drive.changed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_entnommene_disc_ebenso():
|
||||||
|
vorher = {"/dev/sr0": _lw("/dev/sr0", "ready", "bluray")}
|
||||||
|
jetzt = {"/dev/sr0": _lw("/dev/sr0", "empty")}
|
||||||
|
typen = [t for t, _, _ in laufwerks_unterschiede(vorher, jetzt)]
|
||||||
|
assert "disc.removed" in typen
|
||||||
|
|
||||||
|
|
||||||
|
def test_unveraendertes_laufwerk_erzeugt_nichts():
|
||||||
|
stand = {"/dev/sr0": _lw("/dev/sr0", "ready")}
|
||||||
|
assert laufwerks_unterschiede(stand, dict(stand)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_erstes_auftauchen_ist_kein_disc_ereignis():
|
||||||
|
"""Beim Start ist jedes Laufwerk „neu". Wuerde das als disc.inserted
|
||||||
|
zaehlen, spraenge nach jedem API-Neustart der Rip-Dialog auf."""
|
||||||
|
typen = [t for t, _, _ in laufwerks_unterschiede({}, {"/dev/sr0": _lw("/dev/sr0", "ready")})]
|
||||||
|
assert typen == ["drive.changed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verschwundenes_laufwerk_wird_gemeldet():
|
||||||
|
typ, geraet, daten = laufwerks_unterschiede({"/dev/sr0": _lw("/dev/sr0")}, {})[0]
|
||||||
|
assert (typ, geraet) == ("drive.changed", "/dev/sr0")
|
||||||
|
assert daten["status"] == "weg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haengendes_laufwerk_haelt_den_waechter_nicht_an():
|
||||||
|
"""DIE Zusage: Ein defektes Laufwerk darf nicht den Job-Fortschritt
|
||||||
|
mitnehmen. Sonst waere ein klemmendes Laufwerk gleichbedeutend mit einem
|
||||||
|
eingefrorenen UI — und niemand saehe, woran es liegt."""
|
||||||
|
bus = FakeBus()
|
||||||
|
store = FakeStore(jobs=[_job("j1", "ripping", 10)])
|
||||||
|
|
||||||
|
def kaputt():
|
||||||
|
raise OSError("Laufwerk haengt")
|
||||||
|
|
||||||
|
w = Waechter(store, bus, laufwerke_lesen=kaputt, laufwerks_takt=0)
|
||||||
|
w.einmal()
|
||||||
|
store.jobs = [_job("j1", "ripping", 20)]
|
||||||
|
assert w.einmal() == 1 # der Job kommt trotzdem durch
|
||||||
|
assert bus.typen == ["job.progress"]
|
||||||
|
|
||||||
|
|
||||||
|
class FrischGestartet:
|
||||||
|
"""Eine Uhr, die gerade erst angefangen hat zu zählen.
|
||||||
|
|
||||||
|
`time.monotonic()` zählt ab einem beliebigen Nullpunkt — unter Linux ab
|
||||||
|
dem Start der Maschine. Auf einem Rechner, der seit Wochen läuft, ist der
|
||||||
|
Wert riesig; auf einem frisch gestarteten (oder in einem Container) ist er
|
||||||
|
klein. Genau dieser Unterschied hat die Ampel rot gemacht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wert=0.5):
|
||||||
|
self.wert = wert
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.wert
|
||||||
|
|
||||||
|
|
||||||
|
def test_erste_abfrage_faellt_nicht_aus_wenn_die_uhr_bei_null_steht(monkeypatch):
|
||||||
|
"""DER Fehler aus den Ampel-Läufen 181–184 (28.08.2026).
|
||||||
|
|
||||||
|
`_laufwerke_geprueft` stand auf `0.0` und sollte „noch nie geprüft"
|
||||||
|
heißen. Verglichen wurde es aber mit `time.monotonic()`. Auf einer frisch
|
||||||
|
gestarteten Maschine ist `jetzt - 0.0` KLEIN — also sah der Wächter aus,
|
||||||
|
als hätte er die Laufwerke gerade eben schon gelesen, und die erste
|
||||||
|
Abfrage fiel ersatzlos aus.
|
||||||
|
|
||||||
|
Unter Windows fiel das nie auf, weil der Zähler dort schon beim ersten
|
||||||
|
Start groß ist. Dieser Test stellt den kleinen Zähler nach und läuft
|
||||||
|
deshalb auf JEDER Plattform.
|
||||||
|
"""
|
||||||
|
from rippy.bus import waechter as modul
|
||||||
|
|
||||||
|
monkeypatch.setattr(modul, "time", FrischGestartet(0.5))
|
||||||
|
|
||||||
|
aufrufe = []
|
||||||
|
w = Waechter(FakeStore(), FakeBus(),
|
||||||
|
laufwerke_lesen=lambda: aufrufe.append(1) or [_lw("/dev/sr0")],
|
||||||
|
laufwerks_takt=999)
|
||||||
|
w.einmal()
|
||||||
|
assert len(aufrufe) == 1, (
|
||||||
|
"Die erste Laufwerks-Abfrage ist ausgefallen: 0.0 wurde als Zeitpunkt "
|
||||||
|
"gelesen statt als 'noch nie geprueft'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_werden_seltener_abgefragt_als_jobs():
|
||||||
|
"""Ein ioctl kostet mehr als ein SELECT. Ohne den eigenen Takt liefe
|
||||||
|
jede Sekunde eine Laufwerksabfrage — auf einem klemmenden Laufwerk waere
|
||||||
|
das ein Dauerproblem."""
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
def lesen():
|
||||||
|
aufrufe.append(1)
|
||||||
|
return [_lw("/dev/sr0")]
|
||||||
|
|
||||||
|
w = Waechter(FakeStore(), FakeBus(), laufwerke_lesen=lesen, laufwerks_takt=999)
|
||||||
|
w.einmal()
|
||||||
|
w.einmal()
|
||||||
|
w.einmal()
|
||||||
|
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „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 lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
|
||||||
|
# trug keinen `type`; das UI fuegte einen halben Job ein, las
|
||||||
|
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
|
||||||
|
# Fehler die GANZE Oberflaeche mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
|
||||||
|
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
|
||||||
|
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _db_zeile():
|
||||||
|
"""Eine Job-Zeile, NACHDEM `job_form` sie umgewandelt hat.
|
||||||
|
|
||||||
|
⚠️ Hier stand dieselbe Zeile mit denselben Feldnamen — nur habe ich sie
|
||||||
|
mir ausgedacht. Die echte Datenbankzeile heisst `disc_type` und
|
||||||
|
`created_at`, nicht `type` und `startTime`. Der Test hat meine Annahme
|
||||||
|
also bestaetigt statt sie zu pruefen, und beim Commander stand danach
|
||||||
|
„1.1.1970" in der Jobliste.
|
||||||
|
|
||||||
|
Der Waechter bekommt seit dem 29.08.2026 die UMGEWANDELTE Zeile — die
|
||||||
|
Umwandlung selbst prueft `test_api_smoke.py`, wo sie wohnt.
|
||||||
|
"""
|
||||||
|
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
|
||||||
|
"startTime": "2026-08-29T12:07:02", "endTime": None,
|
||||||
|
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
|
||||||
|
"error": None, "meta": {"confidence": 0.3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_umwandlung_kommt_die_rohe_zeile_NICHT_durch():
|
||||||
|
"""Der Waechter darf DB-Feldnamen nicht weiterreichen.
|
||||||
|
|
||||||
|
Die rohe Zeile hat `disc_type`/`created_at`. Wer sie ungewandelt
|
||||||
|
durchreicht, schickt dem UI lauter leere Pflichtfelder — und leer ist
|
||||||
|
schlimmer als fehlend: `new Date(null)` ist der 1.1.1970, sieht also aus
|
||||||
|
wie eine Auskunft.
|
||||||
|
"""
|
||||||
|
roh = {"id": "j1", "disc_type": "bluray", "status": "running",
|
||||||
|
"created_at": "2026-08-29T12:07:02", "progress": 12}
|
||||||
|
kurz = _job_kurz(roh)
|
||||||
|
assert kurz["type"] is None and kurz["startTime"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
|
||||||
|
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
|
||||||
|
leer — ohne Fehlermeldung."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
|
||||||
|
assert kurz[feld] is not None, "%s ist leer" % feld
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_typ_kommt_wirklich_durch():
|
||||||
|
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
|
||||||
|
assert _job_kurz(_db_zeile())["type"] == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
|
||||||
|
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
|
||||||
|
laeuft in der Ereignisschleife des Servers."""
|
||||||
|
kurz = _job_kurz({"id": "j1"})
|
||||||
|
assert kurz["type"] is None and kurz["status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
|
||||||
|
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
|
||||||
|
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
assert "meta" not in kurz
|
||||||
|
assert "endTime" not in kurz
|
||||||
|
|
||||||
|
|
||||||
|
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
|
||||||
|
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten also nichts, obwohl sie mitfahren."""
|
||||||
|
eins = _job_kurz(_db_zeile())
|
||||||
|
zwei = _job_kurz(dict(_db_zeile(), progress=13))
|
||||||
|
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
|
||||||
|
("job.progress", "j1", zwei)]
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"""Der Wächter: macht Änderungen des Workers zu Ereignissen.
|
||||||
|
|
||||||
|
## Das Problem, das er löst
|
||||||
|
|
||||||
|
Der Bus (`memory.py`) verteilt Ereignisse innerhalb EINES Prozesses. Der Worker
|
||||||
|
ist aber ein eigener Prozess — im Docker-Betrieb sogar ein eigener Container,
|
||||||
|
im verteilten Betrieb ein anderer Rechner. Wenn dort der Fortschritt eines Rips
|
||||||
|
von 12 auf 13 Prozent geht, weiß die API davon nichts.
|
||||||
|
|
||||||
|
Drei Wege wären denkbar:
|
||||||
|
|
||||||
|
1. **Der Worker ruft die API an.** Dann hängt jeder Fortschrittswert an einer
|
||||||
|
HTTP-Verbindung, und ein kurzer API-Neustart ließe Ereignisse verschwinden.
|
||||||
|
Der Worker müsste außerdem wissen, wo die API steht — im verteilten Betrieb
|
||||||
|
ist das eine zusätzliche Konfiguration, die schiefgehen kann.
|
||||||
|
2. **Ein gemeinsamer Broker (Redis Pub/Sub).** Sauber, und genau das kommt im
|
||||||
|
verteilten Betrieb (V2-5). Aber der Standalone-Betrieb hat bewusst KEIN
|
||||||
|
Redis — das war der ganze Punkt von V2-2.
|
||||||
|
3. **Die Datenbank fragen.** Sie ist ohnehin die Wahrheit über den Job-Zustand
|
||||||
|
(KONZEPT-V2.md § 3.1), sie ist in JEDEM Betriebsmodus da, und der Worker
|
||||||
|
schreibt dort ohnehin hin.
|
||||||
|
|
||||||
|
Es wird Weg 3.
|
||||||
|
|
||||||
|
## „Ist das nicht wieder Polling?"
|
||||||
|
|
||||||
|
Doch — aber an der Stelle, an der es billig ist, und genau einmal.
|
||||||
|
|
||||||
|
vorher N Browser-Tabs × 9 Endpunkte × alle 4-5 s über HTTP, durch nginx,
|
||||||
|
durch das Rate-Limit
|
||||||
|
jetzt 1 Abfrage/Sekunde lokal, indiziert,
|
||||||
|
im selben Netz wie die DB
|
||||||
|
|
||||||
|
Das Ziel war nie „nirgendwo mehr nachfragen", sondern: **der Browser fragt
|
||||||
|
nicht mehr nach.** Ein offener Tab verursacht jetzt null Anfragen statt 121.
|
||||||
|
Und die Abfrage hier holt vier Spalten von höchstens ein paar Dutzend Zeilen.
|
||||||
|
|
||||||
|
## Was er NICHT tut
|
||||||
|
|
||||||
|
Er schickt keinen Zustand über den Bus, sondern nur die Nachricht, DASS sich
|
||||||
|
etwas geändert hat, plus die geänderten Felder. Wer den vollen Zustand will,
|
||||||
|
holt sich einen Snapshot. Sonst wäre ein verpasstes Ereignis wieder eine
|
||||||
|
Aussage über die Welt.
|
||||||
|
|
||||||
|
## Und wenn er stirbt?
|
||||||
|
|
||||||
|
Dann steht das UI still, ohne es zu merken — der schlimmste Fall. Deshalb:
|
||||||
|
Jeder Fehler wird protokolliert UND als `system.notice` gesendet, und
|
||||||
|
`lebt_seit_sekunden()` macht sein Alter abfragbar. Das ist die Lehre aus
|
||||||
|
AGENTS.md: „Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als
|
||||||
|
einer, der laut scheitert" — dort hatte ein `except Exception: pass` in einer
|
||||||
|
Vorrats-Schleife eine Stunde gekostet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Wie oft nachgesehen wird. Eine Sekunde ist zugleich die Drosselung der
|
||||||
|
# Fortschritts-Ereignisse (KONZEPT-V2.md § 6.3: höchstens 1/s) — schneller
|
||||||
|
# könnte kein Auge folgen, und HandBrake meldet ohnehin nicht öfter.
|
||||||
|
TAKT_SEKUNDEN = 1.0
|
||||||
|
|
||||||
|
# Nach einem Fehler wird langsamer nachgesehen, damit ein dauerhaft kaputter
|
||||||
|
# Zustand (DB weg) nicht jede Sekunde eine Meldung erzeugt.
|
||||||
|
FEHLER_TAKT_SEKUNDEN = 5.0
|
||||||
|
|
||||||
|
# Laufwerke seltener: ein ioctl kostet mehr als ein SELECT, und eine Disc
|
||||||
|
# wird nicht mehrmals pro Sekunde gewechselt. Drei Sekunden entsprechen dem
|
||||||
|
# Takt, den die Disc-Wache in v1 schon hatte.
|
||||||
|
LAUFWERKS_TAKT_SEKUNDEN = 3.0
|
||||||
|
|
||||||
|
# Server-Zustand (Hardware, Worker-Liste, Ablageziele) noch seltener: Das
|
||||||
|
# aendert sich selten, und /storage-targets fasst Netzpfade an. 15 s
|
||||||
|
# entsprechen dem alten UI-Takt von 12 s, nur eben EINMAL statt je Tab.
|
||||||
|
SYSTEM_TAKT_SEKUNDEN = 15.0
|
||||||
|
|
||||||
|
# Endzustände: ab hier ist ein Job durch.
|
||||||
|
ENDE = ("completed", "failed", "canceled")
|
||||||
|
|
||||||
|
|
||||||
|
def _job_kurz(zeile: dict) -> dict:
|
||||||
|
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
|
||||||
|
|
||||||
|
## Warum `type` dazugehört (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 Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
|
||||||
|
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
|
||||||
|
`error` — **keinen `type`**. Das UI fügt so einen halben Job in seine
|
||||||
|
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
|
||||||
|
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
|
||||||
|
Bildschirm, keine Meldung.
|
||||||
|
|
||||||
|
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
|
||||||
|
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
|
||||||
|
fängt den nächsten Fall dieser Art ab.
|
||||||
|
|
||||||
|
**Ein Ereignis „Job angelegt", das den halben Job weglässt, zwingt jeden
|
||||||
|
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten hier nichts und ersparen dem Empfänger die
|
||||||
|
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
|
||||||
|
|
||||||
|
## ⚠️ NACHTRAG 29.08.2026 — beim ersten Anlauf falsch gemacht
|
||||||
|
|
||||||
|
Hier stand `zeile.get("startTime")` und `zeile.get("type")`. **Diese
|
||||||
|
Felder gibt es in der Datenbankzeile nicht.** Sie heißen dort `created_at`
|
||||||
|
und `disc_type`; die Übersetzung macht `_job_row_to_model` in `main.py`,
|
||||||
|
und sie bildet auch `running` auf `processing` ab.
|
||||||
|
|
||||||
|
Die Folge war schlimmer als das Problem davor: Statt eines fehlenden
|
||||||
|
Feldes kam ein leeres. Im Bildschirmfoto des Commanders stand
|
||||||
|
|
||||||
|
TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||||
|
|
||||||
|
— `new Date(null)` ist der 1. Januar 1970, und „DISC" war der Rückfall,
|
||||||
|
den ich zwei Stunden vorher für genau diesen Fall eingebaut hatte. Aus
|
||||||
|
„offensichtlich kaputt" war „sieht plausibel aus" geworden.
|
||||||
|
|
||||||
|
Und mein Test hat es NICHT gefunden, weil ich seine Beispielzeile selbst
|
||||||
|
geschrieben habe — mit meinen erfundenen Feldnamen. Ein Test, der dieselbe
|
||||||
|
Annahme macht wie der Code, prüft nichts. Deshalb wird die Zeile jetzt
|
||||||
|
zuerst durch dieselbe Umwandlung geschickt, die auch `/jobs` benutzt.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": zeile.get("type"),
|
||||||
|
"device": zeile.get("device"),
|
||||||
|
"startTime": zeile.get("startTime"),
|
||||||
|
"status": zeile.get("status"),
|
||||||
|
"progress": zeile.get("progress"),
|
||||||
|
"title": zeile.get("title"),
|
||||||
|
"error": zeile.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
|
||||||
|
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
|
||||||
|
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
|
||||||
|
#: hier und wird mechanisch bewacht.
|
||||||
|
#:
|
||||||
|
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
|
||||||
|
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
|
||||||
|
#: naechste Schnappschuss kam.
|
||||||
|
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
|
||||||
|
|
||||||
|
|
||||||
|
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
||||||
|
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
||||||
|
|
||||||
|
Gibt eine Liste von `(typ, job_id, daten)` zurück. Die Reihenfolge ist
|
||||||
|
festgelegt: erst neue Jobs, dann Änderungen, dann verschwundene. So sieht
|
||||||
|
ein Client einen Job nie „geändert", bevor er ihn kennt.
|
||||||
|
"""
|
||||||
|
# DREI getrennte Durchläufe, nicht einer. Ein einzelner Durchlauf über
|
||||||
|
# `jetzt` würde die Reihenfolge der Einfügung durchreichen — und dann käme
|
||||||
|
# ein „geändert" vor dem „neu" eines anderen Jobs. Ein Client, der ein
|
||||||
|
# job.progress für einen Job bekommt, den er nicht kennt, kann damit
|
||||||
|
# nichts anfangen: Er müsste raten oder einen Snapshot nachfordern.
|
||||||
|
# (Der Test dafür hat genau diesen Fehler in der ersten Fassung gefunden.)
|
||||||
|
neu = []
|
||||||
|
geaendert = []
|
||||||
|
|
||||||
|
for job_id, stand in jetzt.items():
|
||||||
|
if job_id not in vorher:
|
||||||
|
neu.append(("job.created", job_id, stand))
|
||||||
|
continue
|
||||||
|
alt = vorher[job_id]
|
||||||
|
if alt == stand:
|
||||||
|
continue
|
||||||
|
if alt.get("status") != stand.get("status"):
|
||||||
|
typ = "job.finished" if stand.get("status") in ENDE else "job.phase"
|
||||||
|
geaendert.append((typ, job_id, {
|
||||||
|
"von": alt.get("status"), "nach": stand.get("status"),
|
||||||
|
**stand,
|
||||||
|
}))
|
||||||
|
elif alt.get("progress") != stand.get("progress"):
|
||||||
|
geaendert.append(("job.progress", job_id, stand))
|
||||||
|
else:
|
||||||
|
geaendert.append(("job.phase", job_id, stand))
|
||||||
|
|
||||||
|
verschwunden = [
|
||||||
|
("job.finished", job_id, {"status": "geloescht"})
|
||||||
|
for job_id in vorher if job_id not in jetzt
|
||||||
|
]
|
||||||
|
|
||||||
|
return neu + geaendert + verschwunden
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerks_unterschiede(vorher: dict, jetzt: dict) -> list:
|
||||||
|
"""Was hat sich an den Laufwerken geändert? (pure Funktion)
|
||||||
|
|
||||||
|
Der Disc-Wechsel bekommt eigene Ereignisse (`disc.inserted` /
|
||||||
|
`disc.removed`), weil das UI darauf anders reagiert als auf eine bloße
|
||||||
|
Zustandsänderung: Bei einer eingelegten Disc springt der Rip-Dialog auf.
|
||||||
|
"""
|
||||||
|
ereignisse = []
|
||||||
|
for geraet, stand in jetzt.items():
|
||||||
|
alt = vorher.get(geraet)
|
||||||
|
if alt == stand:
|
||||||
|
continue
|
||||||
|
if alt is not None:
|
||||||
|
leer_vorher = alt.get("status") != "ready"
|
||||||
|
leer_jetzt = stand.get("status") != "ready"
|
||||||
|
if leer_vorher and not leer_jetzt:
|
||||||
|
ereignisse.append(("disc.inserted", geraet, stand))
|
||||||
|
elif not leer_vorher and leer_jetzt:
|
||||||
|
ereignisse.append(("disc.removed", geraet, stand))
|
||||||
|
ereignisse.append(("drive.changed", geraet, stand))
|
||||||
|
for geraet in vorher:
|
||||||
|
if geraet not in jetzt:
|
||||||
|
ereignisse.append(("drive.changed", geraet, {"status": "weg"}))
|
||||||
|
return ereignisse
|
||||||
|
|
||||||
|
|
||||||
|
class Waechter:
|
||||||
|
"""Sieht in der Datenbank nach und meldet Änderungen an den Bus.
|
||||||
|
|
||||||
|
`laufwerke_lesen` ist eine Funktion ohne Argumente, die die Laufwerksliste
|
||||||
|
liefert (in der API: `device_info` über alle gefundenen Geräte). Sie wird
|
||||||
|
eingespritzt statt importiert — so ist der Wächter ohne echtes Laufwerk
|
||||||
|
testbar, und im Standalone-Betrieb kann derselbe Wächter den
|
||||||
|
Windows-Treiber benutzen.
|
||||||
|
|
||||||
|
Laufwerke werden SELTENER abgefragt als Jobs: Ein `ioctl` auf einem
|
||||||
|
optischen Laufwerk kostet spürbar mehr als ein SELECT, und eine Disc wird
|
||||||
|
nicht mehrmals pro Sekunde gewechselt. Drei Sekunden entsprechen dem Takt,
|
||||||
|
den die alte Disc-Wache hatte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN,
|
||||||
|
laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_TAKT_SEKUNDEN,
|
||||||
|
system_lesen=None, system_takt: float = SYSTEM_TAKT_SEKUNDEN,
|
||||||
|
job_form=None):
|
||||||
|
# `job_form` uebersetzt eine DB-Zeile in die Form, die das UI kennt
|
||||||
|
# (disc_type -> type, created_at -> startTime, running -> processing).
|
||||||
|
# Eingespritzt, weil die Uebersetzung in main.py wohnt und dieses
|
||||||
|
# Paket nichts von der API weiss. Ohne sie schickte der Waechter die
|
||||||
|
# ROHE Zeile — mit Feldnamen, die es im UI nicht gibt (29.08.2026).
|
||||||
|
self._job_form = job_form or (lambda z: z)
|
||||||
|
self._store = store
|
||||||
|
self._bus = bus
|
||||||
|
self._takt = takt
|
||||||
|
self._laufwerke_lesen = laufwerke_lesen
|
||||||
|
self._laufwerks_takt = laufwerks_takt
|
||||||
|
self._jobs: dict = {}
|
||||||
|
self._laufwerke: dict = {}
|
||||||
|
# None heißt „noch nie geprüft" — NICHT 0.0. `time.monotonic()` zählt
|
||||||
|
# ab einem beliebigen Nullpunkt; unter Linux ist das der Start der
|
||||||
|
# Maschine. Auf einem frisch gestarteten System ist `jetzt - 0.0`
|
||||||
|
# deshalb KLEIN, und die erste Abfrage fiel schlicht aus.
|
||||||
|
#
|
||||||
|
# Auf dem Linux-Runner der Ampel genau so passiert (28.08.2026): null
|
||||||
|
# Laufwerks-Abfragen statt einer. Unter Windows fällt es nie auf, weil
|
||||||
|
# der Zähler dort schon beim ersten Start groß ist — ein Fehler, den
|
||||||
|
# nur die andere Plattform zeigt.
|
||||||
|
self._laufwerke_geprueft = None
|
||||||
|
self._system_lesen = system_lesen
|
||||||
|
self._system_takt = system_takt
|
||||||
|
self._system = None
|
||||||
|
self._system_geprueft = None
|
||||||
|
self._letzte_log_id = None
|
||||||
|
self._letzter_lauf = 0.0
|
||||||
|
self._fehler_in_folge = 0
|
||||||
|
self._laeuft = False
|
||||||
|
|
||||||
|
# ── Abfragbarkeit: wer einen Vorrat anlegt, macht sein Alter sichtbar ──
|
||||||
|
def lebt_seit_sekunden(self) -> float:
|
||||||
|
"""Wie lange ist der letzte erfolgreiche Durchlauf her?
|
||||||
|
|
||||||
|
Gedacht für `GET /health/vorraete`. Ein Wächter, der still gestorben
|
||||||
|
ist, sieht von außen genauso aus wie einer, bei dem gerade nichts
|
||||||
|
passiert — es sei denn, man kann sein Alter erfragen.
|
||||||
|
"""
|
||||||
|
if not self._letzter_lauf:
|
||||||
|
return -1.0
|
||||||
|
return time.monotonic() - self._letzter_lauf
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gesund(self) -> bool:
|
||||||
|
alter = self.lebt_seit_sekunden()
|
||||||
|
return alter >= 0 and alter < self._takt * 10
|
||||||
|
|
||||||
|
# ── Ein Durchlauf ───────────────────────────────────────────────────
|
||||||
|
def einmal(self) -> int:
|
||||||
|
"""Einmal nachsehen und melden. Gibt die Zahl der Ereignisse zurück.
|
||||||
|
|
||||||
|
Bewusst synchron und getrennt von der Schleife: So lässt sie sich
|
||||||
|
ohne asyncio testen.
|
||||||
|
"""
|
||||||
|
gesendet = 0
|
||||||
|
|
||||||
|
jetzt = {z["id"]: _job_kurz(self._job_form(z))
|
||||||
|
for z in self._store.list_jobs(limit=100)}
|
||||||
|
erster_lauf = not self._jobs and self._letzte_log_id is None
|
||||||
|
if not erster_lauf:
|
||||||
|
for typ, job_id, daten in unterschiede(self._jobs, jetzt):
|
||||||
|
self._bus.senden(typ, daten, entitaet="job", entitaet_id=job_id)
|
||||||
|
gesendet += 1
|
||||||
|
self._jobs = jetzt
|
||||||
|
|
||||||
|
zeilen = self._store.list_logs(limit=50)
|
||||||
|
if erster_lauf:
|
||||||
|
self._letzte_log_id = zeilen[0]["id"] if zeilen else 0
|
||||||
|
else:
|
||||||
|
neue = [z for z in zeilen if z["id"] > (self._letzte_log_id or 0)]
|
||||||
|
for zeile in reversed(neue): # älteste zuerst
|
||||||
|
# Feldnamen wie in der Datenbank (level/source/message) — nicht
|
||||||
|
# uebersetzt. Wer sie umbenennt, muss das UI und /logs mitziehen,
|
||||||
|
# und dann stehen zwei Formen nebeneinander.
|
||||||
|
self._bus.senden("log.line", {
|
||||||
|
"level": zeile.get("level"),
|
||||||
|
"source": zeile.get("source"),
|
||||||
|
"message": zeile.get("message"),
|
||||||
|
}, entitaet="log", entitaet_id=str(zeile["id"]))
|
||||||
|
gesendet += 1
|
||||||
|
if neue:
|
||||||
|
self._letzte_log_id = max(z["id"] for z in neue)
|
||||||
|
|
||||||
|
gesendet += self._laufwerke_pruefen(erster_lauf)
|
||||||
|
gesendet += self._system_pruefen()
|
||||||
|
|
||||||
|
self._letzter_lauf = time.monotonic()
|
||||||
|
return gesendet
|
||||||
|
|
||||||
|
def _system_pruefen(self) -> int:
|
||||||
|
"""Server-Zustand im langsamen Takt.
|
||||||
|
|
||||||
|
Anders als bei Jobs wird hier NICHT auf Unterschiede geprüft, sondern
|
||||||
|
der Stand jedes Mal geschickt: Es geht um Messwerte (freier Platz,
|
||||||
|
Auslastung), die sich praktisch immer ändern — ein Vergleich würde nur
|
||||||
|
Rechenzeit kosten und trotzdem jedes Mal „geändert" sagen.
|
||||||
|
|
||||||
|
Fehler werden geschluckt (siehe _laufwerke_pruefen): /storage-targets
|
||||||
|
fasst Netzpfade an, und ein weggebrochenes NAS darf den Job-Fortschritt
|
||||||
|
nicht mitnehmen.
|
||||||
|
"""
|
||||||
|
if self._system_lesen is None:
|
||||||
|
return 0
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
if (self._system_geprueft is not None
|
||||||
|
and jetzt - self._system_geprueft < self._system_takt):
|
||||||
|
return 0
|
||||||
|
self._system_geprueft = jetzt
|
||||||
|
try:
|
||||||
|
stand = self._system_lesen()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
if not stand:
|
||||||
|
return 0
|
||||||
|
self._system = stand
|
||||||
|
self._bus.senden("system.status", stand, entitaet="system")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _laufwerke_pruefen(self, erster_lauf: bool) -> int:
|
||||||
|
"""Laufwerke im eigenen, langsameren Takt.
|
||||||
|
|
||||||
|
Fehler werden hier BEWUSST geschluckt und nicht weitergereicht: Ein
|
||||||
|
hängendes oder defektes Laufwerk darf nicht den ganzen Wächter
|
||||||
|
anhalten — sonst käme auch kein Job-Fortschritt mehr durch. Das UI
|
||||||
|
behält dann seinen letzten Laufwerksstand, was richtig ist: „konnte
|
||||||
|
nicht nachsehen" ist keine Aussage über die Welt.
|
||||||
|
"""
|
||||||
|
if self._laufwerke_lesen is None:
|
||||||
|
return 0
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
if (self._laufwerke_geprueft is not None
|
||||||
|
and jetzt - self._laufwerke_geprueft < self._laufwerks_takt):
|
||||||
|
return 0
|
||||||
|
self._laufwerke_geprueft = jetzt
|
||||||
|
try:
|
||||||
|
stand = {g["path"]: g for g in self._laufwerke_lesen()}
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
gesendet = 0
|
||||||
|
if not erster_lauf and self._laufwerke:
|
||||||
|
for typ, geraet, daten in laufwerks_unterschiede(self._laufwerke, stand):
|
||||||
|
self._bus.senden(typ, daten, entitaet="drive", entitaet_id=geraet)
|
||||||
|
gesendet += 1
|
||||||
|
self._laufwerke = stand
|
||||||
|
return gesendet
|
||||||
|
|
||||||
|
# ── Die Schleife ────────────────────────────────────────────────────
|
||||||
|
async def schleife(self) -> None:
|
||||||
|
"""Läuft, bis sie abgebrochen wird. Meldet Fehler LAUT."""
|
||||||
|
self._laeuft = True
|
||||||
|
while self._laeuft:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self.einmal)
|
||||||
|
self._fehler_in_folge = 0
|
||||||
|
await asyncio.sleep(self._takt)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self._fehler_in_folge += 1
|
||||||
|
# Nur beim ERSTEN Fehler melden und danach alle zehn: Ein
|
||||||
|
# dauerhaft kaputter Zustand soll nicht das Log zumüllen —
|
||||||
|
# aber schweigen darf er auch nicht.
|
||||||
|
if self._fehler_in_folge == 1 or self._fehler_in_folge % 10 == 0:
|
||||||
|
text = (f"Ereignis-Wächter: {e} "
|
||||||
|
f"({self._fehler_in_folge}. Fehlschlag in Folge)")
|
||||||
|
try:
|
||||||
|
self._store.add_log("error", "waechter", text)
|
||||||
|
except Exception:
|
||||||
|
pass # DB weg — dann geht wenigstens der Bus
|
||||||
|
try:
|
||||||
|
self._bus.senden("system.notice",
|
||||||
|
{"level": "error", "text": text})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(FEHLER_TAKT_SEKUNDEN)
|
||||||
|
|
||||||
|
def stoppen(self) -> None:
|
||||||
|
self._laeuft = False
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Konfiguration — eine Präzedenz, in allen drei Betriebsarten dieselbe.
|
||||||
|
|
||||||
|
CLI-Flag > Umgebungsvariable (RIPPY_*) > Konfigurationsdatei > Vorgabe
|
||||||
|
|
||||||
|
## Warum das ein eigenes Modul ist
|
||||||
|
|
||||||
|
v1 liest Einstellungen an drei verschiedenen Stellen und auf drei Arten: aus
|
||||||
|
`os.getenv` (verstreut über tasks.py, ripping.py, db.py), aus der
|
||||||
|
`settings`-Tabelle (`db.get_settings()`) und aus der `.env` über Compose. Wer
|
||||||
|
wissen will, woher ein Wert wirklich kommt, muss alle drei durchsuchen — und
|
||||||
|
`.env.example` warnt an zwei Stellen davor, dass ein vorbelegter Wert „gesund
|
||||||
|
aussieht" und trotzdem falsch ist.
|
||||||
|
|
||||||
|
v2 hat drei Betriebsarten, und jede hat eine andere natürliche Quelle:
|
||||||
|
|
||||||
|
Docker Umgebungsvariablen (kein Dateisystem-Zugriff nötig)
|
||||||
|
Headless /etc/rippy/rippy.toml (dort erwartet man Konfiguration)
|
||||||
|
Windows %ProgramData%\\Rippy\\rippy.toml (der Installer schreibt sie)
|
||||||
|
|
||||||
|
Ohne eine festgelegte Reihenfolge wäre nicht entscheidbar, wer gewinnt. Mit ihr
|
||||||
|
ist es eine Zeile Doku und ein Test.
|
||||||
|
|
||||||
|
## Verschachtelung in Umgebungsvariablen
|
||||||
|
|
||||||
|
`[store] pfad = "…"` heißt `RIPPY_STORE__PFAD` — doppelter Unterstrich als
|
||||||
|
Trenner. Der einfache scheidet aus, weil Schlüssel selbst welche enthalten
|
||||||
|
(`poll_sekunden`).
|
||||||
|
|
||||||
|
## Was hier NICHT hingehört
|
||||||
|
|
||||||
|
Laufzeit-Einstellungen, die der Nutzer im Browser ändert (Presets,
|
||||||
|
Sprachwünsche, Medienserver-Adresse), bleiben in der `settings`-Tabelle. Hier
|
||||||
|
steht nur, was VOR dem Start feststehen muss: wo die Datenbank liegt, welcher
|
||||||
|
Port, welche Treiber. Faustregel: Wenn es einen Neustart braucht, gehört es
|
||||||
|
hierher; wenn nicht, in die Datenbank.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try: # Python 3.11+
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError: # 3.10 — nur der Entwicklungsrechner
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
|
PRAEFIX = "RIPPY_"
|
||||||
|
TRENNER = "__"
|
||||||
|
|
||||||
|
# Die Vorgaben. Sie sind zugleich die Liste dessen, was es überhaupt gibt —
|
||||||
|
# ein Schlüssel, der hier fehlt, wird aus Datei und Umgebung NICHT übernommen
|
||||||
|
# (siehe `_zusammenfuehren`). Das fängt Tippfehler ab, statt sie stillschweigend
|
||||||
|
# zu ignorieren: Ein `RIPPY_STORE__PAFD` soll auffallen, nicht wirkungslos sein.
|
||||||
|
VORGABEN: dict = {
|
||||||
|
"profil": "standalone", # standalone | api | node
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 7788,
|
||||||
|
"ui": True, # statische UI-Assets mitausliefern
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"treiber": "sqlite", # sqlite | postgres
|
||||||
|
"pfad": "", # bei sqlite; leer = Vorgabe je Plattform
|
||||||
|
"url": "", # bei postgres
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"treiber": "lokal", # lokal | celery
|
||||||
|
"broker": "", # bei celery
|
||||||
|
"rip_slots": 1,
|
||||||
|
"encode_slots": 2,
|
||||||
|
"knoten": "", # Anzeigename; leer = Rechnername
|
||||||
|
},
|
||||||
|
"drives": {
|
||||||
|
"erkennung": "poll", # udev | poll
|
||||||
|
"poll_sekunden": 3,
|
||||||
|
"auto_auswurf": True,
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"medien": "",
|
||||||
|
"temp": "",
|
||||||
|
"pfad_timeout_sekunden": 5,
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"tmdb_key": "",
|
||||||
|
"tvdb_key": "",
|
||||||
|
"omdb_key": "",
|
||||||
|
"sprachen": ["de", "en"],
|
||||||
|
},
|
||||||
|
"transcode": {
|
||||||
|
"engine": "handbrake", # handbrake | ffmpeg
|
||||||
|
"encoder": "auto",
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
# Bezugsadresse für Stufe 2 der Schlüsselkette (KONZEPT.md § 10,
|
||||||
|
# Commander-Entscheid 28.08.2026). BEWUSST LEER: Eine vorbelegte
|
||||||
|
# Adresse, die irgendwann tot ist, lässt die Konfiguration gesund
|
||||||
|
# aussehen und scheitert erst im Betrieb — genau die Falle, die in
|
||||||
|
# .env.example beim MakeMKV-Download dokumentiert ist. Ohne Eintrag
|
||||||
|
# ist Stufe 2 schlicht übersprungen.
|
||||||
|
"quelle_url": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _als_typ(wert: str, vorbild: Any) -> Any:
|
||||||
|
"""String aus der Umgebung auf den Typ der Vorgabe bringen.
|
||||||
|
|
||||||
|
Ohne das wäre `RIPPY_SERVER__PORT=8000` der String "8000", und ein
|
||||||
|
Vergleich oder eine Bindung an den Port schlüge fehl — mit einer Meldung,
|
||||||
|
die nach allem Möglichen aussieht, nur nicht nach einem Typfehler.
|
||||||
|
"""
|
||||||
|
if isinstance(vorbild, bool):
|
||||||
|
return wert.strip().lower() in ("1", "true", "ja", "yes", "on")
|
||||||
|
if isinstance(vorbild, int):
|
||||||
|
return int(wert)
|
||||||
|
if isinstance(vorbild, list):
|
||||||
|
return [teil.strip() for teil in wert.split(",") if teil.strip()]
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
|
def aus_umgebung(umgebung: dict = None) -> dict:
|
||||||
|
"""Baut aus RIPPY_*-Variablen einen verschachtelten dict.
|
||||||
|
|
||||||
|
Unbekannte Schlüssel werden ÜBERGANGEN, nicht übernommen — sie tauchen in
|
||||||
|
`unbekannte_schluessel()` auf, damit ein Tippfehler sichtbar wird statt
|
||||||
|
wirkungslos zu bleiben.
|
||||||
|
"""
|
||||||
|
umgebung = os.environ if umgebung is None else umgebung
|
||||||
|
ergebnis: dict = {}
|
||||||
|
for name, wert in umgebung.items():
|
||||||
|
if not name.startswith(PRAEFIX):
|
||||||
|
continue
|
||||||
|
pfad = name[len(PRAEFIX):].lower().split(TRENNER)
|
||||||
|
vorbild = VORGABEN
|
||||||
|
for teil in pfad:
|
||||||
|
if not isinstance(vorbild, dict) or teil not in vorbild:
|
||||||
|
vorbild = None
|
||||||
|
break
|
||||||
|
vorbild = vorbild[teil]
|
||||||
|
if vorbild is None or isinstance(vorbild, dict):
|
||||||
|
continue # unbekannt oder kein Blattwert
|
||||||
|
ziel = ergebnis
|
||||||
|
for teil in pfad[:-1]:
|
||||||
|
ziel = ziel.setdefault(teil, {})
|
||||||
|
ziel[pfad[-1]] = _als_typ(wert, vorbild)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def aus_datei(pfad: str) -> dict:
|
||||||
|
"""Liest eine TOML-Datei. Fehlt sie, ist das kein Fehler — leer zurück.
|
||||||
|
|
||||||
|
Eine KAPUTTE Datei ist dagegen sehr wohl ein Fehler und fliegt hoch: Wer
|
||||||
|
seine Konfiguration verschreibt, soll das beim Start erfahren und nicht
|
||||||
|
stundenlang rätseln, warum eine Einstellung nicht greift.
|
||||||
|
"""
|
||||||
|
if not pfad or not os.path.isfile(pfad):
|
||||||
|
return {}
|
||||||
|
if tomllib is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TOML-Konfiguration braucht Python 3.11 oder neuer "
|
||||||
|
"(auf 3.10 fehlt tomllib). Bis dahin die RIPPY_*-Variablen nutzen."
|
||||||
|
)
|
||||||
|
with open(pfad, "rb") as f:
|
||||||
|
return tomllib.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _zusammenfuehren(basis: dict, oben: dict, vorbild: dict = None) -> dict:
|
||||||
|
"""Legt `oben` über `basis` — rekursiv, und nur bekannte Schlüssel."""
|
||||||
|
vorbild = VORGABEN if vorbild is None else vorbild
|
||||||
|
ergebnis = dict(basis)
|
||||||
|
for schluessel, wert in (oben or {}).items():
|
||||||
|
if schluessel not in vorbild:
|
||||||
|
continue
|
||||||
|
if isinstance(vorbild[schluessel], dict) and isinstance(wert, dict):
|
||||||
|
ergebnis[schluessel] = _zusammenfuehren(
|
||||||
|
ergebnis.get(schluessel, {}), wert, vorbild[schluessel])
|
||||||
|
else:
|
||||||
|
ergebnis[schluessel] = wert
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannte_schluessel(quelle: dict, vorbild: dict = None, pfad: str = "") -> list:
|
||||||
|
"""Alles, was `_zusammenfuehren` verworfen hätte — für `rippy doctor`.
|
||||||
|
|
||||||
|
Ein stillschweigend ignorierter Schlüssel ist dieselbe Fehlerklasse wie ein
|
||||||
|
verschluckter Fehler: Die Konfiguration sieht gesund aus, wirkt aber nicht.
|
||||||
|
"""
|
||||||
|
vorbild = VORGABEN if vorbild is None else vorbild
|
||||||
|
gefunden = []
|
||||||
|
for schluessel, wert in (quelle or {}).items():
|
||||||
|
voll = f"{pfad}.{schluessel}" if pfad else schluessel
|
||||||
|
if schluessel not in vorbild:
|
||||||
|
gefunden.append(voll)
|
||||||
|
elif isinstance(vorbild[schluessel], dict) and isinstance(wert, dict):
|
||||||
|
gefunden.extend(unbekannte_schluessel(wert, vorbild[schluessel], voll))
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def laden(datei: str = None, flags: dict = None, umgebung: dict = None) -> dict:
|
||||||
|
"""Die vollständige Konfiguration nach der Präzedenz oben."""
|
||||||
|
werte = _zusammenfuehren(VORGABEN, aus_datei(datei) if datei else {})
|
||||||
|
werte = _zusammenfuehren(werte, aus_umgebung(umgebung))
|
||||||
|
werte = _zusammenfuehren(werte, flags or {})
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def datenbank_url(werte: dict, standard_pfad: str = None) -> str:
|
||||||
|
"""Übersetzt den store-Abschnitt in eine SQLAlchemy-URL.
|
||||||
|
|
||||||
|
Für `store.verbinden()` — damit die Entscheidung „welche Datenbank" an
|
||||||
|
genau EINER Stelle getroffen wird und nicht an jeder Aufrufstelle neu.
|
||||||
|
"""
|
||||||
|
store = werte.get("store", {})
|
||||||
|
if store.get("treiber") == "postgres":
|
||||||
|
url = (store.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError(
|
||||||
|
"store.treiber ist 'postgres', aber store.url ist leer. "
|
||||||
|
"Ohne Adresse kann Rippy die Datenbank nicht finden."
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
pfad = (store.get("pfad") or "").strip() or standard_pfad or standard_datenbankpfad()
|
||||||
|
return "sqlite:///" + pfad.replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def standard_datenbankpfad() -> str:
|
||||||
|
r"""Wo die SQLite-Datei liegt, wenn niemand etwas anderes sagt.
|
||||||
|
|
||||||
|
## Warum unter Windows NICHT mehr %ProgramData% (28.08.2026)
|
||||||
|
|
||||||
|
Hier stand `%ProgramData%\Rippy\rippy.db`, mit der Begründung: „Ein
|
||||||
|
Programmordner ist unter Windows für einen DIENST nicht zuverlässig
|
||||||
|
beschreibbar." Das stimmte — für den Windows-Dienst, den es nie gab.
|
||||||
|
Rippy läuft als der angemeldete Benutzer (Autostart unter `HKCU`, siehe
|
||||||
|
`windows_app.py`), und der Grund ist damit hinfällig.
|
||||||
|
|
||||||
|
Geblieben war der Schaden. Der Commander meldete: **„Es fehlt der 1st run
|
||||||
|
wizzard wenn man es installiert."** Nachgemessen:
|
||||||
|
|
||||||
|
Installation %LOCALAPPDATA%\Rippy (wird deinstalliert)
|
||||||
|
Datenbank %ProgramData%\Rippy (bleibt liegen)
|
||||||
|
|
||||||
|
Eine „frische" Installation erbte also die alte Datenbank samt
|
||||||
|
`setup.done = true`, und der Ersteinrichtungs-Assistent erschien nie
|
||||||
|
wieder. Ein Deinstallieren, das den Zustand stehen lässt, ist kein
|
||||||
|
Deinstallieren.
|
||||||
|
|
||||||
|
Jetzt liegt alles, was Rippy gehört, in EINEM Ordner: Programm, UI,
|
||||||
|
Werkzeuge, Protokoll, WebView2-Zwischenspeicher — und die Datenbank.
|
||||||
|
|
||||||
|
Unter Linux bleibt `/var/lib/rippy`: Dort läuft Rippy wirklich als
|
||||||
|
Systemdienst, und der FHS sagt genau das.
|
||||||
|
"""
|
||||||
|
if os.name == "nt":
|
||||||
|
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.join(basis, "Rippy", "rippy.db")
|
||||||
|
return "/var/lib/rippy/rippy.db"
|
||||||
|
|
||||||
|
|
||||||
|
def alter_datenbankpfad() -> str:
|
||||||
|
r"""Der Ort VOR dem 28.08.2026 — nur noch zum Umziehen und Aufräumen.
|
||||||
|
|
||||||
|
Wer Rippy schon installiert hatte, hat seine Daten dort. Sie kommentarlos
|
||||||
|
liegen zu lassen wäre ein stiller Verlust; sie zu löschen wäre schlimmer.
|
||||||
|
`windows_app.datenbank_umziehen()` holt sie ab.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return ""
|
||||||
|
basis = os.environ.get("PROGRAMDATA") or ""
|
||||||
|
return os.path.join(basis, "Rippy", "rippy.db") if basis else ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Kern-Bausteine: Zustand, Phasen, Benachrichtigungen (KONZEPT-V2.md § 2.1)."""
|
||||||
@@ -4,9 +4,9 @@ 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
|
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
|
||||||
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
||||||
|
|
||||||
Das Modul existiert bewusst identisch in API und Worker
|
Bis Etappe V2-0 (28.08.2026) lag das Modul zweimal im Repo (docker/api und
|
||||||
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
docker/worker), weil es kein geteiltes Paket gab. Seitdem gibt es `rippy` —
|
||||||
Wer es ändert, ändert BEIDE Dateien.
|
die Datei existiert genau einmal, API und Worker importieren dieselbe.
|
||||||
|
|
||||||
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
||||||
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
|
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
|
||||||
|
|
||||||
from notify import baue_payload, erkenne_webhook_typ
|
from rippy.core.notify import baue_payload, erkenne_webhook_typ
|
||||||
|
|
||||||
|
|
||||||
def test_discord_wird_an_url_erkannt():
|
def test_discord_wird_an_url_erkannt():
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Laufwerks-Schicht: Erkennung, Disc-Status, Verriegeln, Auswurf.
|
||||||
|
|
||||||
|
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen (KONZEPT-V2.md § 5).
|
||||||
|
|
||||||
|
## Wer wählt den Treiber aus
|
||||||
|
|
||||||
|
`treiber()`. Und zwar EINMAL, hier — nicht an jeder Aufrufstelle.
|
||||||
|
|
||||||
|
Bis Etappe V2-4 importierte die API `rippy.drives.linux` direkt. Das war
|
||||||
|
solange harmlos, wie Rippy nur in Containern lief. Für den nativen
|
||||||
|
Windows-Betrieb ist es der Blocker: `linux.py` zieht über `detection.py` das
|
||||||
|
Modul `fcntl` nach, und das gibt es unter Windows nicht. Gemessen am
|
||||||
|
28.08.2026 in einer frischen Python-3.12-Umgebung:
|
||||||
|
|
||||||
|
>>> import main
|
||||||
|
ModuleNotFoundError: No module named 'fcntl'
|
||||||
|
|
||||||
|
Genau dafür ist der Port `rippy.ports.Drives` da: Der Aufrufer sagt, WAS er
|
||||||
|
will, und bekommt den Treiber, der auf DIESER Maschine passt.
|
||||||
|
|
||||||
|
## Warum der Import in der Funktion steht
|
||||||
|
|
||||||
|
Ein `import` auf Modulebene würde beide Treiber laden — und damit unter
|
||||||
|
Windows wieder `fcntl` verlangen. Der Import muss also genau dann passieren,
|
||||||
|
wenn klar ist, welcher gebraucht wird.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def treiber(plattform: str = None):
|
||||||
|
"""Der Laufwerks-Treiber für diese Maschine.
|
||||||
|
|
||||||
|
`plattform` ist nur zum Testen da (Werte wie `sys.platform`): So lässt
|
||||||
|
sich die AUSWAHL prüfen, ohne die Maschine zu wechseln.
|
||||||
|
|
||||||
|
Beide Treiber bieten dieselben Namen an — `list_optical_devices`,
|
||||||
|
`device_info`, `drive_status`, `detect_disc_type`, `disc_size_bytes`,
|
||||||
|
`eject`, `auswerfen_versuchen`, `verriegeln`. Wer hier etwas ergänzt,
|
||||||
|
ergänzt es in BEIDEN; `test_treiberwahl.py` wacht darüber.
|
||||||
|
"""
|
||||||
|
plattform = plattform if plattform is not None else sys.platform
|
||||||
|
if plattform.startswith("win"):
|
||||||
|
# Der Windows-Treiber ist mit der Standalone-App gegangen (Rippy v5,
|
||||||
|
# Entscheid 7). Der Platzhalter hält Importe und Test-Sammlung auf
|
||||||
|
# Windows-Entwicklungsrechnern am Leben; jede BENUTZUNG wirft mit
|
||||||
|
# Klartext — siehe kein_windows.py.
|
||||||
|
from rippy.drives import kein_windows
|
||||||
|
return kein_windows
|
||||||
|
from rippy.drives import linux
|
||||||
|
return linux
|
||||||
|
|
||||||
|
|
||||||
|
def ist_windows(plattform: str = None) -> bool:
|
||||||
|
return (plattform if plattform is not None else sys.platform).startswith("win")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Konstanten und reine Logik rund um optische Laufwerke — OHNE `fcntl`.
|
||||||
|
|
||||||
|
## Warum es diese Datei gibt (Etappe V2-1, 28.08.2026)
|
||||||
|
|
||||||
|
Zwei Gründe, beide praktisch:
|
||||||
|
|
||||||
|
**1. `fcntl` gibt es nur unter Linux.** Solange die ioctl-Nummern in
|
||||||
|
`detection.py` standen, zog jeder Import dieser Nummern auch `fcntl` nach. Als
|
||||||
|
in V2-1 der Auswurf-Treiber (`linux.py`) sie brauchte, war `ripping.py` auf
|
||||||
|
einmal unter Windows nicht mehr ladbar — und der native Windows-Worker lädt
|
||||||
|
genau dieses Modul. Die Tests haben es sofort gefangen; ohne die Trennung hier
|
||||||
|
wäre es beim nächsten Windows-Start aufgefallen.
|
||||||
|
|
||||||
|
**2. Die Zuordnung ist reine Logik und gehört jedem.** `classify()` ist eine
|
||||||
|
Funktion von zwei Zahlen auf einen String. Sie hing nur an Linux, weil sie in
|
||||||
|
derselben Datei stand wie die ioctls — ihre Tests liefen deshalb ausschließlich
|
||||||
|
in der Ampel und nie auf dem Entwicklungsrechner („Tests, die dort landen, sind
|
||||||
|
erst nach dem Push bewiesen"). Jetzt laufen sie überall. Der Windows-Treiber
|
||||||
|
aus V2-4 benutzt dieselbe Funktion: Auch dort wird nach Disc-Status und Größe
|
||||||
|
eingeordnet, nur die Beschaffung der beiden Zahlen unterscheidet sich.
|
||||||
|
|
||||||
|
Die Werte stammen aus der Kernel-UAPI (`include/uapi/linux/cdrom.h`, für
|
||||||
|
BLKGETSIZE64 aus `include/uapi/linux/fs.h`) und sind seit Jahrzehnten stabil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── include/uapi/linux/cdrom.h ──────────────────────────────────────────
|
||||||
|
CDROMEJECT = 0x5309
|
||||||
|
CDROMCLOSETRAY = 0x5319
|
||||||
|
CDROM_DRIVE_STATUS = 0x5326
|
||||||
|
CDROM_DISC_STATUS = 0x5327
|
||||||
|
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
||||||
|
|
||||||
|
# Antworten von CDROM_DRIVE_STATUS
|
||||||
|
CDS_NO_DISC = 1
|
||||||
|
CDS_TRAY_OPEN = 2
|
||||||
|
CDS_DRIVE_NOT_READY = 3
|
||||||
|
CDS_DISC_OK = 4
|
||||||
|
|
||||||
|
# Antworten von CDROM_DISC_STATUS
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Schwellen für die Typ-Zuordnung ─────────────────────────────────────
|
||||||
|
# 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 classify(disc_status_code: int, size_bytes: int) -> str:
|
||||||
|
"""Pure Zuordnung: 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"
|
||||||
@@ -14,32 +14,27 @@ import os
|
|||||||
import struct
|
import struct
|
||||||
from fcntl import ioctl
|
from fcntl import ioctl
|
||||||
|
|
||||||
# include/uapi/linux/cdrom.h
|
# Konstanten und die reine Zuordnung leben in cdrom.py — ohne fcntl, damit
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
# der Windows-Treiber (V2-4) und der native Windows-Worker sie benutzen
|
||||||
CDROM_DISC_STATUS = 0x5327
|
# koennen. Hier werden sie weiter angeboten, weil Aufrufstellen sie so kennen.
|
||||||
|
from rippy.drives.cdrom import ( # noqa: F401
|
||||||
CDS_NO_DISC = 1
|
BLKGETSIZE64,
|
||||||
CDS_TRAY_OPEN = 2
|
BLURAY_MIN_BYTES,
|
||||||
CDS_DRIVE_NOT_READY = 3
|
CDROM_DISC_STATUS,
|
||||||
CDS_DISC_OK = 4
|
CDROM_DRIVE_STATUS,
|
||||||
|
CDS_AUDIO,
|
||||||
CDS_AUDIO = 100
|
CDS_DATA_1,
|
||||||
CDS_DATA_1 = 101
|
CDS_DATA_2,
|
||||||
CDS_DATA_2 = 102
|
CDS_DISC_OK,
|
||||||
CDS_XA_2_1 = 103
|
CDS_DRIVE_NOT_READY,
|
||||||
CDS_XA_2_2 = 104
|
CDS_MIXED,
|
||||||
CDS_MIXED = 105
|
CDS_NO_DISC,
|
||||||
|
CDS_TRAY_OPEN,
|
||||||
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
|
CDS_XA_2_1,
|
||||||
BLKGETSIZE64 = 0x80081272
|
CDS_XA_2_2,
|
||||||
|
UHD_MIN_BYTES,
|
||||||
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
|
classify,
|
||||||
# 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:
|
def _open_nonblock(device_path: str) -> int:
|
||||||
@@ -76,17 +71,6 @@ def disc_size_bytes(device_path: str) -> int:
|
|||||||
os.close(fd)
|
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:
|
def detect_disc_type(device_path: str) -> str:
|
||||||
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
|
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
|
||||||
try:
|
try:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Der Platzhalter, der unter Windows die Wahrheit sagt.
|
||||||
|
|
||||||
|
Der echte Windows-Laufwerkstreiber ist mit der Standalone-App gegangen —
|
||||||
|
Rippy für Windows ist seit dem 30.08.2026 ein eigenes Produkt (Rippy v5,
|
||||||
|
KONZEPT-WINDOWS.md Entscheid 7). Diese Codebasis ist die Docker/Linux-
|
||||||
|
Fassung; der Remote-Encode-Worker auf Windows rippt nicht (Entscheid 8).
|
||||||
|
|
||||||
|
Warum ein Modul statt eines Fehlers in `treiber()`: Entwicklungs- und
|
||||||
|
Test-Läufe auf einem Windows-PC müssen IMPORTIERBAR bleiben (die Ampel-
|
||||||
|
Praxis misst lokal, bevor sie pusht). Erst die BENUTZUNG eines Laufwerks
|
||||||
|
ist hier ein Fehler — und zwar einer mit Ansage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MELDUNG = (
|
||||||
|
"Kein Windows-Laufwerkstreiber in der Docker-Fassung — fuer Windows "
|
||||||
|
"gibt es Rippy v5 (Branch worktree-windows-electron)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlt(*_args, **_kwargs):
|
||||||
|
raise RuntimeError(MELDUNG)
|
||||||
|
|
||||||
|
|
||||||
|
# Dieselben Namen wie linux.py — test_treiberwahl wacht darüber.
|
||||||
|
list_optical_devices = _fehlt
|
||||||
|
device_info = _fehlt
|
||||||
|
drive_status = _fehlt
|
||||||
|
disc_status = _fehlt
|
||||||
|
detect_disc_type = _fehlt
|
||||||
|
disc_size_bytes = _fehlt
|
||||||
|
eject = _fehlt
|
||||||
|
auswerfen_versuchen = _fehlt
|
||||||
|
auswerfen_mit_grund = _fehlt
|
||||||
|
verriegeln = _fehlt
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Laufwerks-Treiber für Linux: finden, Zustand lesen, verriegeln, auswerfen.
|
||||||
|
|
||||||
|
Erfüllt `rippy.ports.Drives`. Der Windows-Treiber (Win32 statt ioctl) kommt in
|
||||||
|
Etappe V2-4 und implementiert denselben Port.
|
||||||
|
|
||||||
|
## Diese Datei war zwei — mit ZWEI VERSCHIEDENEN VERTRÄGEN (V2-1, 28.08.2026)
|
||||||
|
|
||||||
|
Der Auswurf existierte doppelt, und das war die unangenehmere Sorte Doppelung,
|
||||||
|
weil die beiden Fassungen sich nicht nur wiederholten, sondern unterschiedlich
|
||||||
|
ANTWORTETEN:
|
||||||
|
|
||||||
|
docker/api/devices.py eject(pfad) wirft OSError
|
||||||
|
docker/worker/ripping.py wirf_disc_aus(pfad) gibt False zurück, wirft nie
|
||||||
|
|
||||||
|
Beide Verhalten sind richtig — für ihre Seite. Die API will einen Fehler, den
|
||||||
|
sie dem Browser zeigen kann. Der Worker will einen Rip nicht daran scheitern
|
||||||
|
lassen, dass die Schublade klemmt, und er will die ioctls in Tests einspritzen
|
||||||
|
können, ohne ein echtes Laufwerk zu haben.
|
||||||
|
|
||||||
|
Deshalb liegt hier jetzt EINE Mechanik (`auswerfen_mit_grund`) und darüber
|
||||||
|
beide Verträge unverändert. Was verschwindet, ist die dritte Kopie der
|
||||||
|
ioctl-Nummern und der Ablauf-Reihenfolge.
|
||||||
|
|
||||||
|
## Warum überhaupt entriegelt wird (Befund 26.07.2026, am System gemessen)
|
||||||
|
|
||||||
|
Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert
|
||||||
|
nicht, das Laufwerk geht nicht auf." Nachgestellt:
|
||||||
|
|
||||||
|
CDROMEJECT allein -> Erfolg gemeldet
|
||||||
|
CDROM_DRIVE_STATUS danach -> 4 (Disc ist noch drin)
|
||||||
|
CDROM_LOCKDOOR 0 + CDROMEJECT -> Status 2 (Schublade offen)
|
||||||
|
|
||||||
|
MakeMKV verriegelt die Tür während des Rips und entriegelt sie nicht wieder.
|
||||||
|
Ein verriegeltes Laufwerk quittiert den Auswurf **mit Erfolg** und tut nichts.
|
||||||
|
Deshalb gilt für jeden Treiber dieses Ports: erst entriegeln, dann auswerfen,
|
||||||
|
dann NACHSEHEN. Ein Rückgabewert ist kein Beweis, wo die Wirkung prüfbar ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
# NUR aus cdrom.py — das Modul kommt ohne `fcntl` aus. Wuerde hier
|
||||||
|
# detection importiert, waere linux.py (und ueber ripping.py auch der native
|
||||||
|
# Windows-Worker) unter Windows nicht mehr ladbar. Genau das ist beim Bau
|
||||||
|
# dieser Etappe passiert; die Tests haben es gefangen.
|
||||||
|
from rippy.drives.cdrom import ( # noqa: F401
|
||||||
|
CDROM_DRIVE_STATUS,
|
||||||
|
CDROM_LOCKDOOR,
|
||||||
|
CDROMCLOSETRAY,
|
||||||
|
CDROMEJECT,
|
||||||
|
CDS_DISC_OK,
|
||||||
|
CDS_DRIVE_NOT_READY,
|
||||||
|
CDS_NO_DISC,
|
||||||
|
CDS_TRAY_OPEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Was `detection.py` beisteuert — auf Anfrage, nicht beim Import.
|
||||||
|
#
|
||||||
|
# ## Warum das hier stehen MUSS (Ampel rot seit 28.08.2026, Lauf 181–184)
|
||||||
|
#
|
||||||
|
# `docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
|
||||||
|
# Der Windows-Treiber hat die Funktion; dieses Modul hatte sie nicht mehr,
|
||||||
|
# seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
|
||||||
|
# wird. Folge: Unter Windows lief alles, auf Linux starb main.py beim Import
|
||||||
|
# mit `AttributeError: module 'rippy.drives.linux' has no attribute
|
||||||
|
# 'drive_status'` — **der API-Container wäre gar nicht hochgekommen**.
|
||||||
|
#
|
||||||
|
# Lokal fiel es nicht auf, weil hier Windows läuft und `treiber()` dann gar
|
||||||
|
# nicht zu diesem Modul greift. Genau dafür ist die Ampel da — und genau
|
||||||
|
# deshalb ist Regel A („grün, bevor irgendetwas fertig heißt") keine Formalie.
|
||||||
|
#
|
||||||
|
# PEP 562: `__getattr__` wird nur gefragt, wenn der Name nicht schon als
|
||||||
|
# Modul-Variable existiert. Damit bleibt `import rippy.drives.linux` unter
|
||||||
|
# Windows möglich (kein `fcntl` nötig), und wer die Funktionen wirklich
|
||||||
|
# BENUTZT, bekommt sie — oder einen lauten ImportError.
|
||||||
|
_AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
|
||||||
|
"detect_disc_type", "classify")
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name in _AUS_DETECTION:
|
||||||
|
from rippy.drives import detection
|
||||||
|
|
||||||
|
return getattr(detection, name)
|
||||||
|
raise AttributeError("module %r has no attribute %r" % (__name__, name))
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Ergebnisse von auswerfen_mit_grund
|
||||||
|
AUSWURF_OK = "ok"
|
||||||
|
AUSWURF_KEIN_ZUGRIFF = "kein-zugriff" # Gerät ließ sich nicht öffnen
|
||||||
|
AUSWURF_ABGELEHNT = "abgelehnt" # ioctl selbst scheiterte
|
||||||
|
AUSWURF_BLEIBT_DRIN = "bleibt-drin" # angenommen, aber Disc ist noch da
|
||||||
|
|
||||||
|
|
||||||
|
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 auswerfen_mit_grund(device_path: str, ioctl_fn=None, oeffnen=None,
|
||||||
|
schliessen=None, warten=None):
|
||||||
|
"""Die gemeinsame Mechanik. Wirft NIE.
|
||||||
|
|
||||||
|
Rückgabe: `(grund, fehler)` — `grund` ist eine der AUSWURF_*-Konstanten,
|
||||||
|
`fehler` der ursprüngliche OSError (oder None). Der Fehler wird
|
||||||
|
weitergereicht statt verschluckt, damit die API ihn unverändert
|
||||||
|
weiterwerfen kann; vorher stand dort ein eigener `os.open`-Aufruf nur
|
||||||
|
deshalb, weil diese Unterscheidung fehlte.
|
||||||
|
|
||||||
|
Die vier 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 AUSWURF_KEIN_ZUGRIFF, None
|
||||||
|
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 as e:
|
||||||
|
return AUSWURF_KEIN_ZUGRIFF, e
|
||||||
|
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 as e:
|
||||||
|
return AUSWURF_ABGELEHNT, e
|
||||||
|
|
||||||
|
# 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 AUSWURF_OK, None
|
||||||
|
except OSError as e:
|
||||||
|
return AUSWURF_ABGELEHNT, e
|
||||||
|
warten(1)
|
||||||
|
return AUSWURF_BLEIBT_DRIN, None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
schliessen(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def auswerfen_versuchen(device_path: str, ioctl_fn=None, oeffnen=None,
|
||||||
|
schliessen=None, warten=None) -> bool:
|
||||||
|
"""Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers.
|
||||||
|
|
||||||
|
Ein Rip soll nicht daran sterben, dass die Schublade klemmt: `tasks.py`
|
||||||
|
protokolliert das Ergebnis und macht weiter.
|
||||||
|
"""
|
||||||
|
grund, _ = auswerfen_mit_grund(
|
||||||
|
device_path, ioctl_fn=ioctl_fn, oeffnen=oeffnen,
|
||||||
|
schliessen=schliessen, warten=warten,
|
||||||
|
)
|
||||||
|
return grund == AUSWURF_OK
|
||||||
|
|
||||||
|
|
||||||
|
def eject(device_path: str) -> None:
|
||||||
|
"""Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API.
|
||||||
|
|
||||||
|
Die API zeigt die Meldung im Browser, deshalb muss ein Fehlschlag hier
|
||||||
|
laut sein und darf nicht als stilles False untergehen.
|
||||||
|
"""
|
||||||
|
grund, fehler = auswerfen_mit_grund(device_path)
|
||||||
|
if grund == AUSWURF_OK:
|
||||||
|
return
|
||||||
|
if fehler is not None:
|
||||||
|
raise fehler # unverändert weiterreichen (ENOENT, EPERM, …)
|
||||||
|
if grund == AUSWURF_KEIN_ZUGRIFF:
|
||||||
|
raise OSError(f"Das Laufwerk {device_path} ließ sich nicht ansprechen.")
|
||||||
|
raise OSError(
|
||||||
|
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
||||||
|
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verriegeln(device_path: str, an: bool) -> None:
|
||||||
|
"""Tür verriegeln (True) oder entriegeln (False).
|
||||||
|
|
||||||
|
Bisher gab es das nur als Seitenschritt im Auswurf. Als eigene Operation
|
||||||
|
steht es im Port, weil der Windows-Treiber sie ebenfalls braucht
|
||||||
|
(IOCTL_STORAGE_MEDIA_REMOVAL) und weil ein Rip die Tür bewusst zuhalten
|
||||||
|
können soll.
|
||||||
|
"""
|
||||||
|
from fcntl import ioctl
|
||||||
|
|
||||||
|
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
try:
|
||||||
|
ioctl(fd, CDROM_LOCKDOOR, 1 if an else 0)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def list_optical_devices() -> list:
|
||||||
|
"""Alle optischen Laufwerke, die dieser Rechner sieht.
|
||||||
|
|
||||||
|
Im Container sind das die, die per `devices:` durchgereicht wurden.
|
||||||
|
"""
|
||||||
|
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 kennung(device_path: str) -> str:
|
||||||
|
"""Der kurze Name eines Laufwerks — `/dev/sr0` wird zu `sr0`.
|
||||||
|
|
||||||
|
Gegenstueck zu `pfad_zu_kennung`. Begruendung im Windows-Treiber: Hin-
|
||||||
|
und Rueckweg muessen zusammenpassen, sonst antworten die Endpunkte mit
|
||||||
|
404 (Befund 29.08.2026).
|
||||||
|
"""
|
||||||
|
return os.path.basename(device_path)
|
||||||
|
|
||||||
|
|
||||||
|
def pfad_zu_kennung(name: str, geraete=None) -> str:
|
||||||
|
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
|
||||||
|
for pfad in (geraete if geraete is not None else list_optical_devices()):
|
||||||
|
if pfad == name or kennung(pfad) == name:
|
||||||
|
return pfad
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def device_info(device_path: str) -> dict:
|
||||||
|
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
|
||||||
|
|
||||||
|
Kein udevadm: Im Container läuft kein udevd, die udev-Datenbank ist leer —
|
||||||
|
`udevadm info` lieferte dort schlicht nichts (deshalb zeigte der alte Weg
|
||||||
|
nie ein Laufwerk an). `/sys/class/block/<name>/device/*` kommt direkt vom
|
||||||
|
Kernel und funktioniert überall.
|
||||||
|
"""
|
||||||
|
# Erst hier importiert: detection braucht `fcntl`. Auf einer Maschine, die
|
||||||
|
# ueberhaupt Laufwerke abfragt, ist das vorhanden — und wenn nicht, soll es
|
||||||
|
# LAUT scheitern statt beim Import des ganzen Moduls.
|
||||||
|
from rippy.drives.detection import (
|
||||||
|
classify,
|
||||||
|
disc_size_bytes,
|
||||||
|
disc_status,
|
||||||
|
drive_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = kennung(device_path)
|
||||||
|
vendor = read_sys_attr(name, "vendor")
|
||||||
|
model = read_sys_attr(name, "model")
|
||||||
|
|
||||||
|
grund = ""
|
||||||
|
try:
|
||||||
|
status_code = drive_status(device_path)
|
||||||
|
except OSError as e:
|
||||||
|
status_code = -1
|
||||||
|
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
|
disc_type = "unknown"
|
||||||
|
# Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER —
|
||||||
|
# man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1
|
||||||
|
# daran („DIE Regel des Ports", siehe dort); hier stand weiterhin
|
||||||
|
# „empty", und damit behauptete derselbe Ereignisstrom je nach
|
||||||
|
# Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026).
|
||||||
|
status = "unknown" if status_code < 0 else "empty"
|
||||||
|
if status_code == CDS_DISC_OK:
|
||||||
|
status = "ready"
|
||||||
|
try:
|
||||||
|
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||||
|
except OSError as e:
|
||||||
|
disc_type = "unknown"
|
||||||
|
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
# Gleiche Felder wie im Windows-Treiber — das UI unterscheidet
|
||||||
|
# nicht nach Plattform (siehe test_windows: Feld-Parität).
|
||||||
|
"grund": grund,
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Tests für detection.py — die pure Zuordnung classify().
|
"""Tests für die reine Zuordnung classify() aus cdrom.py.
|
||||||
|
|
||||||
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
|
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
|
||||||
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
|
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
|
||||||
nur echte, deterministische Logik getestet — die ioctl-Aufrufe selbst sind
|
nur echte, deterministische Logik getestet — die ioctl-Aufrufe selbst sind
|
||||||
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
|
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
|
||||||
|
|
||||||
|
Seit V2-1 liegen Konstanten und classify() in cdrom.py — ohne `fcntl`.
|
||||||
|
Damit laufen diese Tests auch auf dem Windows-Entwicklungsrechner und
|
||||||
|
nicht mehr nur in der Ampel (vorher: „erst nach dem Push bewiesen").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from detection import (
|
from rippy.drives.cdrom import (
|
||||||
BLURAY_MIN_BYTES,
|
BLURAY_MIN_BYTES,
|
||||||
CDS_AUDIO,
|
CDS_AUDIO,
|
||||||
CDS_DATA_1,
|
CDS_DATA_1,
|
||||||
@@ -50,3 +54,19 @@ def test_uhd_ab_schwelle():
|
|||||||
def test_unbekannter_status():
|
def test_unbekannter_status():
|
||||||
assert classify(999, 5 * 1024**3) == "unknown"
|
assert classify(999, 5 * 1024**3) == "unknown"
|
||||||
assert classify(0, 0) == "unknown"
|
assert classify(0, 0) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── An echter Hardware gemessen ─────────────────────────────────────────
|
||||||
|
def test_gemessene_bd50_wird_als_bluray_eingeordnet():
|
||||||
|
"""Kein ausgedachter Wert: Am 28.08.2026 an einer echten BD-50 im
|
||||||
|
LG BU40N gemessen (IOCTL_DISK_GET_LENGTH_INFO unter Windows).
|
||||||
|
|
||||||
|
Der Fall ist der interessanteste an der ganzen Zuordnung, weil er dicht
|
||||||
|
an der UHD-Schwelle liegt: 44,84 GiB gegen 55 GiB. Wer die Schwelle
|
||||||
|
senkt, macht aus jeder BD-50 eine „UHD" — und Rippy waehlte dann das
|
||||||
|
falsche Kompressions-Preset.
|
||||||
|
"""
|
||||||
|
gemessen = 48_149_364_736 # Bytes, echte BD-50
|
||||||
|
assert gemessen / 1024**3 < 45 # 44,84 GiB
|
||||||
|
assert classify(CDS_DATA_1, gemessen) == "bluray"
|
||||||
|
assert gemessen < UHD_MIN_BYTES
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Tests fuer den Linux-Laufwerks-Treiber — vor allem den Auswurf.
|
||||||
|
|
||||||
|
## Warum diese Tests hier liegen (Etappe V2-1, 28.08.2026)
|
||||||
|
|
||||||
|
Sie standen bis hierher in docker/worker/test_ripping_helpers.py, weil der
|
||||||
|
Auswurf in ripping.py lebte — und ein zweites Mal, anders geschrieben, in
|
||||||
|
docker/api/devices.py. Der Code liegt jetzt an EINER Stelle
|
||||||
|
(rippy/drives/linux.py), also liegen die Tests daneben.
|
||||||
|
|
||||||
|
Neu gegenueber vorher: Auch der ANDERE Vertrag wird geprueft. Die API-Fassung
|
||||||
|
(`eject`) muss werfen, wo die Worker-Fassung (`auswerfen_versuchen`) False
|
||||||
|
zurueckgibt. Genau diese Unterscheidung war vorher ungetestet, weil sie in
|
||||||
|
einer Datei stand, die niemand testete.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rippy.drives import linux
|
||||||
|
|
||||||
|
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
|
||||||
|
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
zustand = {"verriegelt": verriegelt, "status": linux.CDS_DISC_OK,
|
||||||
|
"aufrufe": []}
|
||||||
|
|
||||||
|
def ioctl_fn(fd, befehl, arg=0):
|
||||||
|
zustand["aufrufe"].append(befehl)
|
||||||
|
if befehl == linux.CDROM_LOCKDOOR:
|
||||||
|
if not kennt_lockdoor:
|
||||||
|
raise OSError("ioctl unbekannt")
|
||||||
|
zustand["verriegelt"] = bool(arg)
|
||||||
|
return 0
|
||||||
|
if befehl == linux.CDROMEJECT:
|
||||||
|
if not zustand["verriegelt"]:
|
||||||
|
zustand["status"] = linux.CDS_TRAY_OPEN
|
||||||
|
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
|
||||||
|
if befehl == linux.CDROM_DRIVE_STATUS:
|
||||||
|
return zustand["status"]
|
||||||
|
raise OSError("unerwartetes ioctl")
|
||||||
|
|
||||||
|
return zustand, ioctl_fn
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
|
||||||
|
zustand, ioctl_fn = _laufwerk(verriegelt=True)
|
||||||
|
ok = linux.auswerfen_versuchen(
|
||||||
|
"/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"] == linux.CDS_TRAY_OPEN
|
||||||
|
# Reihenfolge: entriegeln VOR auswerfen
|
||||||
|
assert zustand["aufrufe"][0] == linux.CDROM_LOCKDOOR
|
||||||
|
assert zustand["aufrufe"][1] == linux.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."""
|
||||||
|
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
|
||||||
|
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
|
||||||
|
ok = linux.auswerfen_versuchen(
|
||||||
|
"/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"] == linux.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."""
|
||||||
|
assert linux._auswurf_geglueckt(linux.CDS_NO_DISC) is True
|
||||||
|
assert linux._auswurf_geglueckt(linux.CDS_TRAY_OPEN) is True
|
||||||
|
assert linux._auswurf_geglueckt(linux.CDS_DISC_OK) is False
|
||||||
|
assert linux._auswurf_geglueckt(linux.CDS_DRIVE_NOT_READY) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_ohne_laufwerk_wirft_nicht():
|
||||||
|
def oeffnen_kaputt(p):
|
||||||
|
raise OSError("kein Laufwerk")
|
||||||
|
|
||||||
|
assert linux.auswerfen_versuchen(
|
||||||
|
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
||||||
|
schliessen=lambda fd: None, warten=lambda s: None) is False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der ANDERE Vertrag: eject() der API muss WERFEN ------------------------
|
||||||
|
#
|
||||||
|
# Diese Fälle waren bis V2-1 ungetestet. Der Auswurf existierte zweimal, und
|
||||||
|
# die API-Fassung (docker/api/devices.py) hatte gar keine Testdatei — obwohl
|
||||||
|
# genau sie dem Nutzer im Browser eine Meldung zeigt. Jetzt liegt beides
|
||||||
|
# nebeneinander, also wird auch beides geprüft.
|
||||||
|
|
||||||
|
|
||||||
|
def test_eject_ist_still_wenn_die_disc_rauskommt(monkeypatch):
|
||||||
|
monkeypatch.setattr(linux, "auswerfen_mit_grund",
|
||||||
|
lambda pfad: (linux.AUSWURF_OK, None))
|
||||||
|
assert linux.eject("/dev/sr0") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_eject_wirft_wenn_die_disc_drin_bleibt(monkeypatch):
|
||||||
|
"""Der Unterschied zum Worker-Vertrag: hier ist ein stilles False falsch.
|
||||||
|
|
||||||
|
Der Commander drückt im Browser auf „Auswerfen". Passiert nichts und die
|
||||||
|
API schweigt, sucht er den Fehler am Laufwerk — genau der Verlauf vom
|
||||||
|
26.07.2026.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(linux, "auswerfen_mit_grund",
|
||||||
|
lambda pfad: (linux.AUSWURF_BLEIBT_DRIN, None))
|
||||||
|
try:
|
||||||
|
linux.eject("/dev/sr0")
|
||||||
|
except OSError as e:
|
||||||
|
assert "noch" in str(e) and "drin" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("eject haette werfen muessen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_eject_reicht_den_urspruenglichen_fehler_unveraendert_weiter(monkeypatch):
|
||||||
|
"""ENOENT/EPERM sollen NICHT hinter einer eigenen Meldung verschwinden.
|
||||||
|
|
||||||
|
Vorher warf devices.eject den rohen OSError von os.open. Wer den ersetzt,
|
||||||
|
nimmt dem Nutzer die einzige brauchbare Auskunft („Datei nicht gefunden"
|
||||||
|
gegen „keine Berechtigung" sind zwei ganz verschiedene Probleme).
|
||||||
|
"""
|
||||||
|
original = OSError(2, "No such file or directory")
|
||||||
|
monkeypatch.setattr(linux, "auswerfen_mit_grund",
|
||||||
|
lambda pfad: (linux.AUSWURF_KEIN_ZUGRIFF, original))
|
||||||
|
try:
|
||||||
|
linux.eject("/dev/sr9")
|
||||||
|
except OSError as e:
|
||||||
|
assert e is original
|
||||||
|
else:
|
||||||
|
raise AssertionError("eject haette werfen muessen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswerfen_mit_grund_unterscheidet_die_fehlerarten():
|
||||||
|
"""Die gemeinsame Mechanik muss sagen KÖNNEN, was schiefging — sonst
|
||||||
|
kann eject() den Unterschied nicht machen."""
|
||||||
|
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
|
||||||
|
grund, fehler = linux.auswerfen_mit_grund(
|
||||||
|
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
||||||
|
schliessen=lambda fd: None, warten=lambda s: None)
|
||||||
|
assert grund == linux.AUSWURF_BLEIBT_DRIN
|
||||||
|
assert fehler is None
|
||||||
|
|
||||||
|
def oeffnen_kaputt(p):
|
||||||
|
raise OSError(13, "Permission denied")
|
||||||
|
|
||||||
|
grund, fehler = linux.auswerfen_mit_grund(
|
||||||
|
"/dev/sr0", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
||||||
|
schliessen=lambda fd: None, warten=lambda s: None)
|
||||||
|
assert grund == linux.AUSWURF_KEIN_ZUGRIFF
|
||||||
|
assert fehler.errno == 13
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Die Treiberwahl — und die Zusage, dass beide Treiber dasselbe können.
|
||||||
|
|
||||||
|
## Warum das ein eigener Test ist
|
||||||
|
|
||||||
|
Wenn ein Treiber eine Funktion hat und der andere nicht, merkt das niemand
|
||||||
|
beim Bauen. Es fällt erst auf der anderen Plattform auf, mitten im Betrieb,
|
||||||
|
als `AttributeError` — und dann steht Rippy.
|
||||||
|
|
||||||
|
Genau das war bis V2-4 der Zustand: Die API importierte `rippy.drives.linux`
|
||||||
|
direkt, und unter Windows scheiterte schon der Import von `main.py` mit
|
||||||
|
`ModuleNotFoundError: No module named 'fcntl'` (am 28.08.2026 in einer
|
||||||
|
frischen Python-3.12-Umgebung gemessen).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy import drives
|
||||||
|
|
||||||
|
# Was jeder Treiber können MUSS. Wer hier etwas ergänzt, ergänzt es in beiden
|
||||||
|
# Treibern — sonst wird dieser Test rot, und zwar auf jeder Plattform.
|
||||||
|
PFLICHT = (
|
||||||
|
"list_optical_devices",
|
||||||
|
"device_info",
|
||||||
|
"drive_status",
|
||||||
|
"disc_status",
|
||||||
|
"disc_size_bytes",
|
||||||
|
"detect_disc_type",
|
||||||
|
"eject",
|
||||||
|
"auswerfen_versuchen",
|
||||||
|
"auswerfen_mit_grund",
|
||||||
|
"verriegeln",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_bekommt_den_windows_treiber():
|
||||||
|
assert drives.treiber("win32").__name__.endswith("windows")
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_bekommt_den_linux_treiber():
|
||||||
|
assert drives.treiber("linux").__name__.endswith("linux")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_plattform_bekommt_linux():
|
||||||
|
"""macOS und BSD landen bei Linux — die ioctls sind dort nicht identisch,
|
||||||
|
aber ein Treiber, der ehrlich scheitert, ist besser als gar keiner. Der
|
||||||
|
macOS-Treiber steht in KONZEPT-V2.md § 5 als eigener Punkt."""
|
||||||
|
assert drives.treiber("darwin").__name__.endswith("linux")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_angabe_gilt_diese_maschine():
|
||||||
|
erwartet = "windows" if sys.platform.startswith("win") else "linux"
|
||||||
|
assert drives.treiber().__name__.endswith(erwartet)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ist_windows():
|
||||||
|
assert drives.ist_windows("win32") is True
|
||||||
|
assert drives.ist_windows("linux") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", PFLICHT)
|
||||||
|
def test_windows_treiber_kann_alles_pflichtgemaesse(name):
|
||||||
|
assert callable(getattr(drives.treiber("win32"), name, None)), (
|
||||||
|
f"Dem Windows-Treiber fehlt {name}(). Das faellt sonst erst im "
|
||||||
|
"Betrieb als AttributeError auf — auf der anderen Plattform."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform.startswith("win"),
|
||||||
|
reason="der Linux-Treiber braucht fcntl")
|
||||||
|
@pytest.mark.parametrize("name", PFLICHT)
|
||||||
|
def test_linux_treiber_kann_alles_pflichtgemaesse(name):
|
||||||
|
assert callable(getattr(drives.treiber("linux"), name, None)), (
|
||||||
|
f"Dem Linux-Treiber fehlt {name}(). Das faellt sonst erst im Betrieb "
|
||||||
|
"als AttributeError auf — auf der anderen Plattform."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Was `linux.py` nicht selbst hat, sondern aus `detection.py` durchreicht.
|
||||||
|
# Getrennt gefuehrt, weil genau diese Trennung der Fehler war.
|
||||||
|
AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
|
||||||
|
"detect_disc_type", "classify")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", AUS_DETECTION)
|
||||||
|
def test_linux_treiber_reicht_die_detection_funktionen_durch(name, monkeypatch):
|
||||||
|
"""DER Fehler, der die Ampel vier Commits lang rot hielt (28.08.2026).
|
||||||
|
|
||||||
|
`docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
|
||||||
|
Der Windows-Treiber hat die Funktion. `linux.py` hatte sie NICHT mehr,
|
||||||
|
seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
|
||||||
|
wird — sonst waere `main.py` unter Windows nicht ladbar.
|
||||||
|
|
||||||
|
Folge: Unter Windows lief alles, auf Linux starb `main.py` beim Import
|
||||||
|
mit `AttributeError: module 'rippy.drives.linux' has no attribute
|
||||||
|
'drive_status'`. **Der API-Container waere gar nicht hochgekommen.**
|
||||||
|
|
||||||
|
Der Test oben faellt unter Windows aus (er braucht `fcntl`) — dieser
|
||||||
|
hier NICHT: Er schiebt eine Attrappe von `detection` unter und prueft
|
||||||
|
nur die Durchreiche. Damit haette der Fehler auch hier auffallen muessen,
|
||||||
|
nicht erst auf dem Linux-Runner.
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
import rippy.drives
|
||||||
|
from rippy.drives import linux
|
||||||
|
|
||||||
|
attrappe = types.ModuleType("rippy.drives.detection")
|
||||||
|
for wie_es_heisst in AUS_DETECTION:
|
||||||
|
setattr(attrappe, wie_es_heisst,
|
||||||
|
lambda *a, _n=wie_es_heisst, **k: _n)
|
||||||
|
|
||||||
|
# BEIDES ersetzen, nicht nur sys.modules. `from rippy.drives import
|
||||||
|
# detection` sieht zuerst im PAKET nach (`_handle_fromlist` prueft
|
||||||
|
# `hasattr`) und greift erst dann auf sys.modules zurueck. Auf Linux ist
|
||||||
|
# das echte Modul dort laengst gesetzt, weil es importierbar ist — die
|
||||||
|
# Attrappe blieb wirkungslos und der Test lief in die echten Funktionen
|
||||||
|
# (Ampel-Lauf 186: `TypeError: drive_status() missing 1 required
|
||||||
|
# positional argument`). Unter Windows fiel das nicht auf: Dort gibt es
|
||||||
|
# das Attribut mangels `fcntl` gar nicht.
|
||||||
|
monkeypatch.setattr(rippy.drives, "detection", attrappe, raising=False)
|
||||||
|
monkeypatch.setitem(sys.modules, "rippy.drives.detection", attrappe)
|
||||||
|
|
||||||
|
durchgereicht = getattr(linux, name, None)
|
||||||
|
assert callable(durchgereicht), (
|
||||||
|
f"linux.py reicht {name}() nicht durch — genau daran ist die API "
|
||||||
|
"auf Linux beim Import gestorben."
|
||||||
|
)
|
||||||
|
assert durchgereicht() == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_namen_werfen_weiter(monkeypatch):
|
||||||
|
"""Die Durchreiche darf kein Sammelbecken werden: Ein Tippfehler muss
|
||||||
|
ein AttributeError bleiben, sonst verschwindet er still."""
|
||||||
|
from rippy.drives import linux
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
linux.gibt_es_nicht
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
r"""Pfade nach IHREN Regeln behandeln, nicht nach denen der laufenden Maschine.
|
||||||
|
|
||||||
|
## Warum es dieses Modul gibt (28.08.2026, zweimal bezahlt)
|
||||||
|
|
||||||
|
`os.path` richtet sich nach der Plattform, auf der es gerade läuft. Das ist
|
||||||
|
fast immer richtig — und in diesem Projekt an drei Stellen falsch, weil dort
|
||||||
|
über Pfade einer ANDEREN Maschine gerechnet wird:
|
||||||
|
|
||||||
|
* `tools/katalog.py` baut Windows-Installationsorte (`C:\Program Files\…`).
|
||||||
|
Auf dem Linux-Runner der Ampel wurde daraus
|
||||||
|
`C:\Program Files (x86)\MakeMKV/makemkvcon64.exe` — ein Pfad, den es auf
|
||||||
|
keiner Maschine gibt. Fünf Tests rot, und zwar NUR auf Linux.
|
||||||
|
* `platform/verknuepfungen.py` leitet den Arbeitsordner einer `.lnk` ab. Eine
|
||||||
|
`.lnk` zeigt IMMER auf einen Windows-Pfad, auch wenn der Code gerade auf
|
||||||
|
Linux läuft. `os.path.dirname` gab dort einen leeren String.
|
||||||
|
* `betrieb.py` vergleicht Laufwerksbuchstaben und sucht das nächste
|
||||||
|
vorhandene Elternverzeichnis. Auf Linux kannte `splitdrive` kein `D:`, und
|
||||||
|
`dirname` zerlegte `C:\Users\Test\…` nicht.
|
||||||
|
|
||||||
|
Dreimal dasselbe Muster, dreimal einzeln repariert. Beim dritten Mal gehört
|
||||||
|
es an EINE Stelle — sonst kommt es ein viertes Mal wieder.
|
||||||
|
|
||||||
|
## Die Regel
|
||||||
|
|
||||||
|
**Der Pfad entscheidet, nicht der Rechner.** Ein Laufwerksbuchstabe oder ein
|
||||||
|
Backslash heißt Windows; alles andere heißt POSIX.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ntpath
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
|
||||||
|
def ist_windows_pfad(pfad: str) -> bool:
|
||||||
|
r"""`C:\…` oder irgendein Backslash — dann ist es ein Windows-Pfad."""
|
||||||
|
pfad = pfad or ""
|
||||||
|
return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":")
|
||||||
|
|
||||||
|
|
||||||
|
def _modul(pfad: str):
|
||||||
|
return ntpath if ist_windows_pfad(pfad) else posixpath
|
||||||
|
|
||||||
|
|
||||||
|
def verbinden(basis: str, *teile) -> str:
|
||||||
|
r"""Pfadteile mit dem Trenner der BASIS verbinden.
|
||||||
|
|
||||||
|
Achtung, Windows-Eigenheit: `verbinden("D:", "Rippy")` ergibt `D:Rippy`,
|
||||||
|
NICHT `D:\Rippy`. `D:` ohne Backslash bedeutet „der aktuelle Ordner auf
|
||||||
|
Laufwerk D" — `ntpath.join` setzt deshalb absichtlich keinen Trenner. Das
|
||||||
|
ist richtig so, auch wenn es überrascht.
|
||||||
|
"""
|
||||||
|
return _modul(basis).join(basis, *teile)
|
||||||
|
|
||||||
|
|
||||||
|
def ordner_von(datei: str) -> str:
|
||||||
|
"""Der Ordner einer Datei — nach dem Trenner der DATEI."""
|
||||||
|
return _modul(datei).dirname(datei)
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerk_von(pfad: str) -> str:
|
||||||
|
r"""Der Laufwerksteil (`C:`, `\\server\freigabe`) — leer bei POSIX-Pfaden.
|
||||||
|
|
||||||
|
Für die Frage „liegen zwei Pfade auf demselben Laufwerk". Zwei leere
|
||||||
|
Ergebnisse heißen dabei „beide POSIX" und damit ebenfalls: dasselbe.
|
||||||
|
"""
|
||||||
|
return _modul(pfad).splitdrive(pfad)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def gleiches_laufwerk(a: str, b: str) -> bool:
|
||||||
|
"""Liegen beide auf demselben Laufwerk? (ohne Rücksicht auf Groß/Klein)"""
|
||||||
|
return laufwerk_von(a).lower() == laufwerk_von(b).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
||||||
|
"""Der nächste Ordner nach oben, den es WIRKLICH gibt. "" wenn keiner.
|
||||||
|
|
||||||
|
Frisch installiert gibt es den Ablage-Ordner noch nicht — `disk_usage`
|
||||||
|
wirft dann, und im Dashboard stand „unbekannt". Der Nutzer will aber
|
||||||
|
wissen, ob auf dem LAUFWERK Platz ist, und das lässt sich beantworten.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
existiert = existiert or os.path.isdir
|
||||||
|
modul = _modul(pfad)
|
||||||
|
pfad = (pfad or "").strip()
|
||||||
|
# Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als
|
||||||
|
# der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist
|
||||||
|
# unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel
|
||||||
|
# (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in
|
||||||
|
# `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe:
|
||||||
|
# aus "\\server\freigabe\" darf kein Ort ohne Trenner werden.
|
||||||
|
gekuerzt = pfad.rstrip("\\/")
|
||||||
|
if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]:
|
||||||
|
pfad = gekuerzt
|
||||||
|
gesehen = set()
|
||||||
|
while pfad and pfad not in gesehen:
|
||||||
|
if existiert(pfad):
|
||||||
|
return pfad
|
||||||
|
gesehen.add(pfad)
|
||||||
|
eltern = modul.dirname(pfad)
|
||||||
|
if eltern == pfad: # Wurzel erreicht
|
||||||
|
break
|
||||||
|
pfad = eltern
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Plattform-Anbindung: Windows-Dienst/Tray/Registry, systemd, Prozessstart.
|
||||||
|
|
||||||
|
Alles, was NUR auf einer Plattform gilt und nichts mit dem Fachkern zu tun
|
||||||
|
hat. Der Kern kennt diese Module nicht — er kennt die Ports.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Was eine Windows-Datei über sich selbst sagt — und warum das hier zählt.
|
||||||
|
|
||||||
|
## Wozu (28.08.2026)
|
||||||
|
|
||||||
|
Rippy lädt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil
|
||||||
|
makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu
|
||||||
|
holen und ungeprüft auszuführen wäre leichtsinnig.
|
||||||
|
|
||||||
|
Der naheliegende Schutz — die digitale Signatur — geht NICHT:
|
||||||
|
|
||||||
|
Get-AuthenticodeSignature Setup_MakeMKV_v1.18.4.exe
|
||||||
|
Status: NotSigned
|
||||||
|
|
||||||
|
Am 28.08.2026 an der echten Datei gemessen: **MakeMKV signiert seinen
|
||||||
|
Installer nicht.** Eine Signaturprüfung wäre also eine Prüfung, die immer
|
||||||
|
fehlschlägt — schlimmer als keine, weil sie Sicherheit vortäuscht und dann
|
||||||
|
den richtigen Weg blockiert.
|
||||||
|
|
||||||
|
Prüfbar ist die **Versions-Ressource**. An derselben Datei gemessen:
|
||||||
|
|
||||||
|
CompanyName GuinpinSoft inc
|
||||||
|
FileDescription MakeMKV installer
|
||||||
|
FileVersion v1.18.4
|
||||||
|
ProductName MakeMKV
|
||||||
|
|
||||||
|
Das ist kein Ersatz für eine Signatur — wer die Datei fälscht, kann auch die
|
||||||
|
Ressource fälschen. Aber es fängt zuverlässig ab, was hier wirklich droht:
|
||||||
|
eine Fehlerseite, ein umbenanntes Archiv, eine falsche Version, ein
|
||||||
|
abgebrochener Download. Und es sagt dem Nutzer, WAS er da bekommen hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
FELDER = ("CompanyName", "FileDescription", "FileVersion", "ProductName",
|
||||||
|
"ProductVersion")
|
||||||
|
|
||||||
|
|
||||||
|
def versionsangaben(pfad: str) -> dict:
|
||||||
|
"""Die Versions-Ressource einer Windows-Datei. Leer, wenn keine da ist.
|
||||||
|
|
||||||
|
Wirft nicht: Eine Datei ohne Ressource ist ein gültiges Ergebnis (und
|
||||||
|
genau das, was eine Fehlerseite mit `.exe`-Namen hätte).
|
||||||
|
"""
|
||||||
|
if os.name != "nt" or not os.path.isfile(pfad):
|
||||||
|
return {}
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = ctypes.WinDLL("version")
|
||||||
|
groesse = version.GetFileVersionInfoSizeW(pfad, None)
|
||||||
|
if not groesse:
|
||||||
|
return {}
|
||||||
|
puffer = ctypes.create_string_buffer(groesse)
|
||||||
|
if not version.GetFileVersionInfoW(pfad, 0, groesse, puffer):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
zeiger = ctypes.c_void_p()
|
||||||
|
laenge = ctypes.c_uint()
|
||||||
|
if not version.VerQueryValueW(puffer, r"\VarFileInfo\Translation",
|
||||||
|
ctypes.byref(zeiger), ctypes.byref(laenge)):
|
||||||
|
return {}
|
||||||
|
paar = ctypes.cast(zeiger, ctypes.POINTER(ctypes.c_uint16))
|
||||||
|
sprache = "%04x%04x" % (paar[0], paar[1])
|
||||||
|
|
||||||
|
ergebnis = {}
|
||||||
|
for feld in FELDER:
|
||||||
|
z = ctypes.c_void_p()
|
||||||
|
n = ctypes.c_uint()
|
||||||
|
pfad_im_block = "\\StringFileInfo\\%s\\%s" % (sprache, feld)
|
||||||
|
if version.VerQueryValueW(puffer, pfad_im_block,
|
||||||
|
ctypes.byref(z), ctypes.byref(n)) and n.value:
|
||||||
|
ergebnis[feld] = ctypes.wstring_at(z.value, n.value - 1).strip()
|
||||||
|
return ergebnis
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def ist_programm(pfad: str) -> bool:
|
||||||
|
"""Fängt die Datei mit `MZ` an? (Das tut jedes Windows-Programm.)
|
||||||
|
|
||||||
|
Der billigste und wirksamste Test gegen das, was wirklich passiert: eine
|
||||||
|
HTML-Fehlerseite, die unter dem Namen `Setup_MakeMKV_v1.18.4.exe`
|
||||||
|
gespeichert wurde.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(pfad, "rb") as f:
|
||||||
|
return f.read(2) == b"MZ"
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Prüfung als reine Funktion ──────────────────────────────────────
|
||||||
|
def passt(angaben: dict, firma: str = "", produkt: str = "",
|
||||||
|
version: str = "") -> tuple:
|
||||||
|
"""Passen die Angaben zu dem, was erwartet wird? (ja/nein, Begründung)
|
||||||
|
|
||||||
|
Reine Funktion — damit jeder Fall prüfbar ist, ohne eine Datei zu haben.
|
||||||
|
Leere Erwartungen werden nicht geprüft: Wer nichts fordert, bekommt kein
|
||||||
|
Urteil vorgesetzt.
|
||||||
|
"""
|
||||||
|
if not angaben:
|
||||||
|
return False, "Die Datei hat keine Versionsangaben — das ist kein " \
|
||||||
|
"Installationsprogramm, sondern vermutlich eine Fehlerseite."
|
||||||
|
|
||||||
|
gefunden_firma = angaben.get("CompanyName", "")
|
||||||
|
if firma and firma.lower() not in gefunden_firma.lower():
|
||||||
|
return False, ("Die Datei stammt laut ihren eigenen Angaben von "
|
||||||
|
"%r, erwartet war %r." % (gefunden_firma or "niemandem", firma))
|
||||||
|
|
||||||
|
text = " ".join(angaben.get(f, "") for f in
|
||||||
|
("ProductName", "FileDescription")).lower()
|
||||||
|
if produkt and produkt.lower() not in text:
|
||||||
|
return False, ("Die Datei bezeichnet sich als %r, erwartet war %r."
|
||||||
|
% (text.strip() or "nichts", produkt))
|
||||||
|
|
||||||
|
if version:
|
||||||
|
gefunden = (angaben.get("FileVersion") or
|
||||||
|
angaben.get("ProductVersion") or "").lstrip("vV")
|
||||||
|
if gefunden and not gefunden.startswith(version):
|
||||||
|
return False, ("Die Datei ist Fassung %s, erwartet war %s."
|
||||||
|
% (gefunden, version))
|
||||||
|
|
||||||
|
return True, "%s %s" % (angaben.get("ProductName", "?"),
|
||||||
|
angaben.get("FileVersion", "?"))
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Was eine Windows-Datei ueber sich selbst sagt.
|
||||||
|
|
||||||
|
## Warum das geprueft wird
|
||||||
|
|
||||||
|
Rippy laedt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil
|
||||||
|
makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu holen
|
||||||
|
und ungeprueft auszufuehren waere leichtsinnig.
|
||||||
|
|
||||||
|
Der naheliegende Schutz -- die digitale Signatur -- geht NICHT: Am 28.08.2026
|
||||||
|
an der echten Datei gemessen, `Get-AuthenticodeSignature` sagt `NotSigned`.
|
||||||
|
MakeMKV signiert seinen Installer nicht. Eine Signaturpruefung waere also eine
|
||||||
|
Pruefung, die immer fehlschlaegt.
|
||||||
|
|
||||||
|
Geprueft wird deshalb die Versions-Ressource. An derselben Datei gemessen:
|
||||||
|
|
||||||
|
CompanyName GuinpinSoft inc
|
||||||
|
FileDescription MakeMKV installer
|
||||||
|
FileVersion v1.18.4
|
||||||
|
|
||||||
|
Alle Pruefungen hier sind rein -- keine Datei noetig, laeuft auf jeder
|
||||||
|
Plattform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from rippy.platform import dateiangaben as d
|
||||||
|
|
||||||
|
ECHT = {
|
||||||
|
"CompanyName": "GuinpinSoft inc",
|
||||||
|
"FileDescription": "MakeMKV installer",
|
||||||
|
"FileVersion": "v1.18.4",
|
||||||
|
"ProductName": "MakeMKV",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_echte_datei_wird_erkannt():
|
||||||
|
gut, grund = d.passt(ECHT, firma="GuinpinSoft", produkt="MakeMKV",
|
||||||
|
version="1.18.4")
|
||||||
|
assert gut is True, grund
|
||||||
|
assert "1.18.4" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_versionsangaben_ist_es_keine_programmdatei():
|
||||||
|
"""Das ist der haeufigste Fall: eine Fehlerseite mit .exe-Namen."""
|
||||||
|
gut, grund = d.passt({}, firma="GuinpinSoft")
|
||||||
|
assert gut is False
|
||||||
|
assert "Fehlerseite" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_firma_wird_abgelehnt():
|
||||||
|
fremd = dict(ECHT, CompanyName="Irgendwer GmbH")
|
||||||
|
gut, grund = d.passt(fremd, firma="GuinpinSoft")
|
||||||
|
assert gut is False
|
||||||
|
assert "Irgendwer" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsche_version_wird_abgelehnt():
|
||||||
|
"""Ein Archiv-Schnappschuss koennte eine aeltere Fassung liefern."""
|
||||||
|
gut, grund = d.passt(ECHT, firma="GuinpinSoft", version="1.19.0")
|
||||||
|
assert gut is False
|
||||||
|
assert "1.18.4" in grund and "1.19.0" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremdes_produkt_wird_abgelehnt():
|
||||||
|
fremd = dict(ECHT, ProductName="Notepad", FileDescription="Editor")
|
||||||
|
gut, _ = d.passt(fremd, firma="GuinpinSoft", produkt="MakeMKV")
|
||||||
|
assert gut is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_erwartung_wird_nichts_geurteilt():
|
||||||
|
"""Wer nichts fordert, bekommt kein Urteil vorgesetzt."""
|
||||||
|
gut, _ = d.passt(ECHT)
|
||||||
|
assert gut is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_v_vor_der_version_stoert_nicht():
|
||||||
|
"""Die Datei meldet 'v1.18.4', erwartet wird '1.18.4'."""
|
||||||
|
gut, _ = d.passt(ECHT, version="1.18.4")
|
||||||
|
assert gut is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mz_pruefung_an_einer_echten_datei(tmp_path):
|
||||||
|
programm = tmp_path / "p.exe"
|
||||||
|
programm.write_bytes(b"MZ" + b"\x00" * 100)
|
||||||
|
assert d.ist_programm(str(programm)) is True
|
||||||
|
|
||||||
|
seite = tmp_path / "fehler.exe"
|
||||||
|
seite.write_bytes(b"<html>525</html>")
|
||||||
|
assert d.ist_programm(str(seite)) is False
|
||||||
|
|
||||||
|
assert d.ist_programm(str(tmp_path / "gibt-es-nicht.exe")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_versionsangaben_ohne_windows_sind_leer():
|
||||||
|
if os.name == "nt":
|
||||||
|
import pytest
|
||||||
|
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||||
|
assert d.versionsangaben("/bin/sh") == {}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
||||||
|
|
||||||
|
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
||||||
|
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
||||||
|
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
||||||
|
|
||||||
|
## Warum das nicht Nebensache ist
|
||||||
|
|
||||||
|
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
||||||
|
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
||||||
|
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
||||||
|
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
||||||
|
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
||||||
|
AGENTS.md warnt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
|
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
||||||
|
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
||||||
|
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
||||||
|
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
||||||
|
if os.name == "nt":
|
||||||
|
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
||||||
|
else:
|
||||||
|
assert winlauf.OHNE_FENSTER == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
||||||
|
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
||||||
|
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
||||||
|
monkeypatch.setattr(sys, "stdout", None)
|
||||||
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
||||||
|
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
||||||
|
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
||||||
|
was jemand gebrauchen kann."""
|
||||||
|
class Puffer:
|
||||||
|
write = staticmethod(lambda _: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "stdout", Puffer())
|
||||||
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
||||||
|
datei = tmp_path / "echt.txt"
|
||||||
|
with open(datei, "w", encoding="utf-8") as f:
|
||||||
|
monkeypatch.setattr(sys, "stdout", f)
|
||||||
|
assert winlauf.ohne_konsole() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
||||||
|
def test_protokoll_liegt_bei_rippy():
|
||||||
|
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
||||||
|
verschwindet beim Deinstallieren mit."""
|
||||||
|
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
||||||
|
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
||||||
|
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
||||||
|
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
||||||
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
|
try:
|
||||||
|
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
||||||
|
print("eine Zeile mit Umlaut: ä")
|
||||||
|
sys.stdout.flush()
|
||||||
|
finally:
|
||||||
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
|
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
||||||
|
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
||||||
|
— also genau das, was man dann sucht."""
|
||||||
|
ziel = str(tmp_path / "rippy.log")
|
||||||
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
|
try:
|
||||||
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
|
print("erster Start")
|
||||||
|
sys.stdout.flush()
|
||||||
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
|
print("zweiter Start")
|
||||||
|
sys.stdout.flush()
|
||||||
|
finally:
|
||||||
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
|
inhalt = open(ziel, encoding="utf-8").read()
|
||||||
|
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
||||||
|
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
||||||
|
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
||||||
|
ohne Protokoll."""
|
||||||
|
def geht_nicht(*a, **k):
|
||||||
|
raise OSError("kein Platz")
|
||||||
|
|
||||||
|
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
||||||
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
|
try:
|
||||||
|
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
||||||
|
print("das darf nicht werfen")
|
||||||
|
finally:
|
||||||
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
|
|
||||||
|
|
||||||
|
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
||||||
|
def test_ohne_windows_kein_meldungsfenster():
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||||
|
assert winlauf.meldung_zeigen("egal") is False
|
||||||
|
assert winlauf.an_elternkonsole_haengen() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
|
||||||
|
#
|
||||||
|
# Commander, mit Bildschirmfoto:
|
||||||
|
#
|
||||||
|
# Failed to remove temporary directory:
|
||||||
|
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
|
||||||
|
#
|
||||||
|
# „Und manchmal kommt dieser fehler."
|
||||||
|
#
|
||||||
|
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
|
||||||
|
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
|
||||||
|
# Sperre war also voruebergehend, es ist ein Wettlauf.
|
||||||
|
#
|
||||||
|
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
|
||||||
|
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
|
||||||
|
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
|
||||||
|
# will loeschen — und die Kinder halten die DLLs offen.
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
|
||||||
|
"""Der Test, der 1,1 GB Reste verhindert haette."""
|
||||||
|
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
|
||||||
|
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
|
||||||
|
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
|
||||||
|
"_PYI_PARENT_PROCESS_LEVEL": "1"}
|
||||||
|
kind = winlauf.umgebung_ohne_bundle(eltern)
|
||||||
|
for name in winlauf.PYI_ZEIGER:
|
||||||
|
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
|
||||||
|
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_elternumgebung_wird_nicht_veraendert():
|
||||||
|
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
|
||||||
|
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
|
||||||
|
winlauf.umgebung_ohne_bundle(eltern)
|
||||||
|
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
|
||||||
|
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
|
||||||
|
|
||||||
|
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
|
||||||
|
weggeraeumt, bevor es an der gesperrten DLL scheitert.
|
||||||
|
"""
|
||||||
|
lebt = tmp_path / "_MEI111111"
|
||||||
|
lebt.mkdir()
|
||||||
|
(lebt / "python312.dll").write_bytes(b"MZ")
|
||||||
|
(lebt / "wichtig.pyd").write_bytes(b"x")
|
||||||
|
|
||||||
|
def gesperrt(pfad):
|
||||||
|
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
|
||||||
|
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
|
||||||
|
jetzt_loeschen=gesperrt) == 0
|
||||||
|
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufraeumen_entfernt_die_reste(tmp_path):
|
||||||
|
for name in ("_MEI000035002", "_MEI0000b0882"):
|
||||||
|
ordner = tmp_path / name
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "python312.dll").write_bytes(b"MZ")
|
||||||
|
(ordner / "base_library.zip").write_bytes(b"PK")
|
||||||
|
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
|
||||||
|
assert not list(tmp_path.glob("_MEI*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
|
||||||
|
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
|
||||||
|
eigen = tmp_path / "_MEI999999"
|
||||||
|
eigen.mkdir()
|
||||||
|
(eigen / "python312.dll").write_bytes(b"MZ")
|
||||||
|
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
|
||||||
|
assert eigen.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
|
||||||
|
"""Was nicht `_MEI` heisst, geht uns nichts an."""
|
||||||
|
(tmp_path / "wichtige-daten").mkdir()
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
|
||||||
|
assert (tmp_path / "wichtige-daten").exists()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Kein makemkvcon ueberlebt Rippy — und keine Waise haelt das Laufwerk fest.
|
||||||
|
|
||||||
|
## Der Befund (29.08.2026, auf dem Rechner des Commanders gemessen)
|
||||||
|
|
||||||
|
Ein Elternprozess startete `makemkvcon`, dann wurde er hart beendet
|
||||||
|
(`taskkill /F` ohne `/T` — genau das, was beim Dienst-Stopp und beim
|
||||||
|
Drueber-Installieren passiert):
|
||||||
|
|
||||||
|
ohne Leine makemkvcon PID 15368 vor dem Kill: True danach: True
|
||||||
|
mit Leine makemkvcon PID 43608 vor dem Kill: True danach: False
|
||||||
|
|
||||||
|
Ohne Leine bleibt die Waise stehen. **Und eine Waise haelt das Laufwerk fest**
|
||||||
|
— jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl".
|
||||||
|
|
||||||
|
Diese Tests pruefen die Logik, nicht das Betriebssystem: Sie laufen auch unter
|
||||||
|
Linux (Vertrag der Portschicht) und brauchen keine Windows-Aufrufe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wen raeumt Rippy weg, und wen nicht ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_waise_wird_erkannt():
|
||||||
|
liste = [
|
||||||
|
(100, 4, "explorer.exe"),
|
||||||
|
(200, 999, "makemkvcon64.exe"), # Elternprozess 999 gibt es nicht
|
||||||
|
]
|
||||||
|
assert winlauf.waisen_finden(liste) == [(200, "makemkvcon64.exe")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_makemkvcon_mit_lebendem_eltern_bleibt_unangetastet():
|
||||||
|
"""Das gehoert zu einem laufenden Rip — oder zu einem zweiten Rippy."""
|
||||||
|
liste = [
|
||||||
|
(100, 4, "Rippy.exe"),
|
||||||
|
(200, 100, "makemkvcon64.exe"), # Kind eines lebenden Rippy
|
||||||
|
]
|
||||||
|
assert winlauf.waisen_finden(liste) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_makemkv_oberflaeche_wird_nie_abgeschossen():
|
||||||
|
"""`makemkv.exe` ist das Programm, das der Commander offen haben darf.
|
||||||
|
Nur `makemkvcon` ist das Kommandozeilen-Werkzeug, das Rippy startet."""
|
||||||
|
liste = [(200, 999, "makemkv.exe")]
|
||||||
|
assert winlauf.waisen_finden(liste) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_und_flac_zaehlen_mit():
|
||||||
|
liste = [(1, 999, "HandBrakeCLI.exe"), (2, 999, "flac.exe")]
|
||||||
|
assert {name for _, name in winlauf.waisen_finden(liste)} == {
|
||||||
|
"HandBrakeCLI.exe", "flac.exe"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gross_und_kleinschreibung_ist_egal():
|
||||||
|
"""Windows meldet mal `makemkvcon64.exe`, mal anders geschrieben."""
|
||||||
|
assert winlauf.waisen_finden([(1, 999, "MakeMKVcon64.EXE")]) == [
|
||||||
|
(1, "MakeMKVcon64.EXE")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_beenden_meldet_was_es_beendet_hat():
|
||||||
|
liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")]
|
||||||
|
versucht = []
|
||||||
|
|
||||||
|
def toeten(pid):
|
||||||
|
versucht.append(pid)
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert winlauf.waisen_beenden(liste, toeten=toeten) == [
|
||||||
|
"makemkvcon64.exe", "flac.exe"]
|
||||||
|
assert versucht == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_prozess_der_sich_nicht_beenden_laesst_bricht_nichts_ab():
|
||||||
|
"""Ein fremder Prozess mit demselben Namen, an den Rippy nicht herankommt,
|
||||||
|
darf den Start nicht aufhalten — die anderen werden trotzdem weggeraeumt."""
|
||||||
|
liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")]
|
||||||
|
|
||||||
|
def toeten(pid):
|
||||||
|
if pid == 1:
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert winlauf.waisen_beenden(liste, toeten=toeten) == ["flac.exe"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Leine ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_windows_gibt_es_keine_leine():
|
||||||
|
"""Unter Linux uebernimmt das der Container bzw. systemd — nicht Rippy."""
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("hier laeuft Windows")
|
||||||
|
assert winlauf.kinder_an_die_leine() is False
|
||||||
|
assert winlauf.prozessliste() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Arbeitsgruppen gibt es nur unter Windows")
|
||||||
|
def test_die_leine_haelt_und_bleibt_gehalten():
|
||||||
|
assert winlauf.kinder_an_die_leine() is True
|
||||||
|
griff = winlauf._ARBEITSGRUPPE
|
||||||
|
assert griff, "der Griff MUSS liegen bleiben — sonst sterben alle Kinder sofort"
|
||||||
|
assert winlauf.kinder_an_die_leine() is True, "ein zweiter Aufruf ist harmlos"
|
||||||
|
assert winlauf._ARBEITSGRUPPE == griff, "und legt keine zweite Gruppe an"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="braucht echte Prozesse")
|
||||||
|
def test_die_prozessliste_sieht_diesen_prozess():
|
||||||
|
eigene = {pid for pid, _, _ in winlauf.prozessliste()}
|
||||||
|
assert os.getpid() in eigene
|
||||||
|
|
||||||
|
|
||||||
|
# ── Waechter gegen die Falle, die beim Bauen zugeschnappt ist ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeder_kernel32_aufruf_hat_eine_angemeldete_signatur():
|
||||||
|
"""⚠️ Ohne `argtypes` reicht ctypes einen Griff als 32-Bit-int weiter.
|
||||||
|
|
||||||
|
`GetCurrentProcess()` liefert den Pseudogriff (HANDLE)-1, also
|
||||||
|
0xFFFFFFFFFFFFFFFF — ctypes wirft dann `ArgumentError: int too long to
|
||||||
|
convert`, das breite `except` verschluckt es, und `kinder_an_die_leine`
|
||||||
|
meldete nur „ging nicht". Genau so ist der erste Anlauf gescheitert.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
quelle = inspect.getsource(winlauf.kernel32)
|
||||||
|
for name in ("CreateJobObjectW", "SetInformationJobObject",
|
||||||
|
"AssignProcessToJobObject", "GetCurrentProcess",
|
||||||
|
"CreateToolhelp32Snapshot", "OpenProcess", "TerminateProcess"):
|
||||||
|
assert f"k.{name}.argtypes" in quelle, f"{name} ohne argtypes"
|
||||||
|
assert f"k.{name}.restype" in quelle, f"{name} ohne restype"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Strukturen nur unter Windows")
|
||||||
|
def test_die_struktur_hat_die_groesse_die_windows_erwartet():
|
||||||
|
"""144 Byte auf 64-Bit — am 29.08.2026 gegen Windows gemessen. Stimmt die
|
||||||
|
Groesse nicht, lehnt SetInformationJobObject stumm ab."""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
assert ctypes.sizeof(winlauf._ERWEITERTE_GRENZEN()) == 144
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_rueckfall_ohne_die_fahne_ist_vorhanden():
|
||||||
|
"""Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis,
|
||||||
|
verweigert Windows den Start rundweg. Ein Fenster, das gar nicht mehr
|
||||||
|
aufgeht, waere schlimmer als eines, das mitstirbt."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
quelle = inspect.getsource(winlauf.eigenstaendig_starten)
|
||||||
|
assert "except OSError" in quelle
|
||||||
|
assert quelle.count("subprocess.Popen") == 2, "mit Fahne und ohne"
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
## Die eigene Konsole (28.08.2026)
|
||||||
|
|
||||||
|
Commander: *„Unterbinde das CMD Fenster was sich beim Öffnen mitöffnet."*
|
||||||
|
|
||||||
|
Der erste Anlauf wollte das Fenster VERSTECKEN: `GetConsoleProcessList`
|
||||||
|
fragen, und bei genau einem angehängten Prozess `ShowWindow(SW_HIDE)`. Zwei
|
||||||
|
Gründe, warum das nicht reicht:
|
||||||
|
|
||||||
|
1. **Eine Onefile-EXE ist ZWEI Prozesse** — ein Starter und die eigentliche
|
||||||
|
Anwendung. An der Konsole hängen also immer zwei. Die Prüfung war damit
|
||||||
|
nie wahr, und das Fenster blieb einfach stehen. (Gemessen: PID 33172 mit
|
||||||
|
8 MB, PID 33580 mit 103 MB — beide `Rippy.exe`.)
|
||||||
|
2. Selbst wenn sie ginge, wäre das Fenster erst nach ein bis zwei Sekunden
|
||||||
|
weg — so lange braucht Python bis zur ersten eigenen Zeile. Ein
|
||||||
|
Aufblitzen ist kein „unterbunden".
|
||||||
|
|
||||||
|
**Die Konsole wird deshalb gar nicht erst erzeugt.** Die EXE ist als
|
||||||
|
Fenster-Programm gebaut (`--windowed`), nicht als Konsolen-Programm. Windows
|
||||||
|
legt dann für keinen Start eine Konsole an.
|
||||||
|
|
||||||
|
Der Preis: Es gibt keine Standardausgabe mehr — `sys.stdout` ist `None`.
|
||||||
|
Deshalb die drei Funktionen hier unten:
|
||||||
|
|
||||||
|
an_elternkonsole_haengen() in einem Terminal gestartet? Dann DORT
|
||||||
|
hineinschreiben (fuer --status, --hilfe)
|
||||||
|
ausgabe_umleiten() sonst: in eine Protokolldatei
|
||||||
|
meldung_zeigen() und was der Nutzer sehen MUSS, kommt in
|
||||||
|
ein Fenster statt in ein schwarzes Nichts
|
||||||
|
|
||||||
|
Ein Programm ohne Konsole, das seine Meldungen ins Leere schreibt, wäre
|
||||||
|
genau der stille Fehlschlag, vor dem AGENTS.md warnt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
ATTACH_PARENT_PROCESS = -1
|
||||||
|
MB_OK = 0x0
|
||||||
|
MB_ICONINFORMATION = 0x40
|
||||||
|
MB_ICONERROR = 0x10
|
||||||
|
|
||||||
|
|
||||||
|
#: PyInstaller schreibt den Auspack-Ordner in die eigene Umgebung. Namen je
|
||||||
|
#: nach Fassung: `_MEIPASS2` bis 5.x, ab 6.x die drei `_PYI_*`-Variablen
|
||||||
|
#: (pyinstaller/PyInstaller/loader/pyiboot01_bootstrap.py). Hier stehen alle,
|
||||||
|
#: damit ein Fassungswechsel den Fehler nicht stillschweigend zurückholt.
|
||||||
|
PYI_ZEIGER = ("_MEIPASS2", "_PYI_ARCHIVE_FILE", "_PYI_APPLICATION_HOME_DIR",
|
||||||
|
"_PYI_PARENT_PROCESS_LEVEL")
|
||||||
|
|
||||||
|
|
||||||
|
def umgebung_ohne_bundle(basis: dict = None) -> dict:
|
||||||
|
"""Die eigene Umgebung, aber ohne PyInstallers Auspack-Zeiger.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (28.08.2026)
|
||||||
|
|
||||||
|
Failed to remove temporary directory:
|
||||||
|
C:\\Users\\TobisPC\\AppData\\Local\\Temp\\_MEI0000b0882
|
||||||
|
|
||||||
|
„Und manchmal kommt dieser fehler." — Manchmal stimmt: Es ist ein
|
||||||
|
Wettlauf.
|
||||||
|
|
||||||
|
## Was gemessen wurde
|
||||||
|
|
||||||
|
In seinem Temp-Ordner lagen **20 zurückgelassene `_MEI`-Ordner mit
|
||||||
|
zusammen 1,1 GB**. Einer davon war der aus der Meldung, und er ließ sich
|
||||||
|
hinterher anstandslos löschen — die Sperre war also vorübergehend.
|
||||||
|
|
||||||
|
## Die Ursache
|
||||||
|
|
||||||
|
Eine Onefile-EXE packt sich beim Start nach `%TEMP%\\_MEIxxxxxx` aus und
|
||||||
|
räumt beim Beenden auf. Den Ordner findet der Prozess über eine
|
||||||
|
Umgebungsvariable, die PyInstaller sich selbst setzt.
|
||||||
|
|
||||||
|
`starte_hintergrund` und `starte_fensterprozess` starten `Rippy.exe`
|
||||||
|
erneut — mit `--dienst` und mit `--oeffnen`, denn Tray und Fenster
|
||||||
|
brauchen je einen eigenen Haupt-Thread. `subprocess.Popen` ohne `env=`
|
||||||
|
reicht die ganze Umgebung weiter, also **auch diesen Zeiger**. Die beiden
|
||||||
|
Kinder packen daraufhin gar nichts mehr aus: Sie laufen im Ordner des
|
||||||
|
Elternprozesses.
|
||||||
|
|
||||||
|
Der Eltern-Prozess beendet sich als Erster und will seinen Ordner
|
||||||
|
löschen. Die Kinder haben die DLLs darin noch offen — Windows verweigert.
|
||||||
|
Meldung. Und weil danach niemand mehr zuständig ist, bleibt der Ordner
|
||||||
|
für immer liegen; beim nächsten Start derselbe Ablauf.
|
||||||
|
|
||||||
|
Das war nicht nur unschön: Wäre das Löschen TEILWEISE geglückt, hätten
|
||||||
|
Dienst und Fenster mitten im Betrieb ihre eigenen Dateien verloren.
|
||||||
|
|
||||||
|
Ohne den Zeiger packt sich jedes Kind seinen eigenen Ordner aus und räumt
|
||||||
|
ihn selbst wieder weg. Das kostet je Start etwa eine Sekunde und ein paar
|
||||||
|
Dutzend MB kurzzeitig — deutlich billiger als 1,1 GB Reste.
|
||||||
|
"""
|
||||||
|
umgebung = dict(os.environ if basis is None else basis)
|
||||||
|
for name in PYI_ZEIGER:
|
||||||
|
umgebung.pop(name, None)
|
||||||
|
return umgebung
|
||||||
|
|
||||||
|
|
||||||
|
def reste_aufraeumen(ordner: str = None, eigener: str = None,
|
||||||
|
jetzt_loeschen=None) -> int:
|
||||||
|
"""Räumt zurückgelassene `_MEI`-Ordner weg. Gibt die Anzahl zurück.
|
||||||
|
|
||||||
|
Der Netzfang für alles, was der Zeiger-Fix nicht mehr erzeugt, aber schon
|
||||||
|
liegen ließ — und für abgestürzte Läufe, die es immer geben wird.
|
||||||
|
|
||||||
|
## Warum das gefahrlos ist
|
||||||
|
|
||||||
|
Ein LAUFENDER Rippy hat seine `python312.dll` offen, und Windows lässt
|
||||||
|
eine offene Datei nicht löschen. Deshalb wird genau die zuerst versucht:
|
||||||
|
Geht sie nicht weg, gehört der Ordner einem lebenden Prozess, und es wird
|
||||||
|
**nichts weiter angefasst**. Ein blindes `rmtree(ignore_errors=True)`
|
||||||
|
hätte einem laufenden Rippy die halbe Bibliothek unter den Füßen
|
||||||
|
weggeräumt, bevor es an der gesperrten DLL scheitert.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ordner = ordner or tempfile.gettempdir()
|
||||||
|
eigener = eigener if eigener is not None else getattr(sys, "_MEIPASS", "")
|
||||||
|
weg = 0
|
||||||
|
for pfad in glob.glob(os.path.join(ordner, "_MEI*")):
|
||||||
|
if not os.path.isdir(pfad) or os.path.normcase(pfad) == os.path.normcase(eigener or "\0"):
|
||||||
|
continue
|
||||||
|
wache = [d for d in glob.glob(os.path.join(pfad, "python3*.dll"))]
|
||||||
|
try:
|
||||||
|
for datei in wache:
|
||||||
|
(jetzt_loeschen or os.remove)(datei)
|
||||||
|
except OSError:
|
||||||
|
continue # In Benutzung — Finger weg vom ganzen Ordner.
|
||||||
|
shutil.rmtree(pfad, ignore_errors=True)
|
||||||
|
weg += 1
|
||||||
|
return weg
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════ Kinder ueberleben Rippy nicht ══
|
||||||
|
#
|
||||||
|
# ## Der Befund (29.08.2026, auf diesem Rechner gemessen)
|
||||||
|
#
|
||||||
|
# Ein Python-Elternprozess startete `makemkvcon`, dann wurde der Elternprozess
|
||||||
|
# hart beendet (`taskkill /F`, ohne `/T` — genau das, was Windows beim
|
||||||
|
# Dienst-Stopp und beim Drueber-Installieren tut):
|
||||||
|
#
|
||||||
|
# vor dem Kill: Eltern True, Kind True
|
||||||
|
# danach: Eltern False, Kind True <-- die Waise
|
||||||
|
#
|
||||||
|
# Zwei `makemkvcon64.exe` blieben danach im Taskmanager stehen. **Eine solche
|
||||||
|
# Waise haelt das Laufwerk fest.** Jeder spaetere Rip scheitert dann mit
|
||||||
|
# „Das Oeffnen der Disk schlug fehl" — dem Satz, den der Commander gemeldet hat.
|
||||||
|
#
|
||||||
|
# Windows raeumt Kindprozesse nicht auf. Es gibt genau ein Mittel dagegen, und
|
||||||
|
# das ist die Arbeitsgruppe (Job Object): Prozesse darin sterben, wenn die
|
||||||
|
# Gruppe geschlossen wird — und geschlossen wird sie automatisch, wenn der
|
||||||
|
# letzte Griff darauf verschwindet, also wenn Rippy endet. Auch beim Absturz.
|
||||||
|
#
|
||||||
|
# ⚠️ `_ARBEITSGRUPPE` MUSS ein Modulwert bleiben. Wird der Griff eingesammelt,
|
||||||
|
# schliesst Windows die Gruppe — und bringt damit sofort alle Kinder um.
|
||||||
|
|
||||||
|
TH32CS_SNAPPROCESS = 0x00000002
|
||||||
|
JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||||
|
JOB_OBJECT_ERWEITERTE_GRENZEN = 9
|
||||||
|
CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||||
|
PROCESS_TERMINATE = 0x0001
|
||||||
|
|
||||||
|
# Nur diese werden je als Waise beendet. `makemkv.exe` steht bewusst NICHT
|
||||||
|
# dabei: Das ist die Oberflaeche von MakeMKV, die der Commander offen haben
|
||||||
|
# darf, ohne dass Rippy sie abschiesst.
|
||||||
|
WERKZEUG_PROZESSE = ("makemkvcon.exe", "makemkvcon64.exe",
|
||||||
|
"handbrakecli.exe", "flac.exe")
|
||||||
|
|
||||||
|
_ARBEITSGRUPPE = None
|
||||||
|
_K32 = None
|
||||||
|
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
class _BASIS_GRENZEN(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||||
|
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||||
|
("LimitFlags", wintypes.DWORD),
|
||||||
|
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||||
|
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||||
|
("ActiveProcessLimit", wintypes.DWORD),
|
||||||
|
("Affinity", ctypes.c_size_t),
|
||||||
|
("PriorityClass", wintypes.DWORD),
|
||||||
|
("SchedulingClass", wintypes.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _EA_ZAEHLER(ctypes.Structure):
|
||||||
|
_fields_ = [(name, ctypes.c_ulonglong) for name in (
|
||||||
|
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
||||||
|
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
||||||
|
|
||||||
|
class _ERWEITERTE_GRENZEN(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("BasicLimitInformation", _BASIS_GRENZEN),
|
||||||
|
("IoInfo", _EA_ZAEHLER),
|
||||||
|
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||||
|
("JobMemoryLimit", ctypes.c_size_t),
|
||||||
|
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||||
|
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _PROZESSEINTRAG(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("dwSize", wintypes.DWORD),
|
||||||
|
("cntUsage", wintypes.DWORD),
|
||||||
|
("th32ProcessID", wintypes.DWORD),
|
||||||
|
("th32DefaultHeapID", ctypes.c_size_t),
|
||||||
|
("th32ModuleID", wintypes.DWORD),
|
||||||
|
("cntThreads", wintypes.DWORD),
|
||||||
|
("th32ParentProcessID", wintypes.DWORD),
|
||||||
|
("pcPriClassBase", ctypes.c_long),
|
||||||
|
("dwFlags", wintypes.DWORD),
|
||||||
|
("szExeFile", wintypes.WCHAR * 260),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def eigenstaendig_starten(befehl, **kw):
|
||||||
|
"""Startet einen Prozess, der Rippy UEBERLEBEN soll.
|
||||||
|
|
||||||
|
Drei Prozesse duerfen nicht mitsterben: der Dienst (vom Starter aus), das
|
||||||
|
Fensterprogramm und das Aufraeum-Skript der Deinstallation. Sie loesen
|
||||||
|
sich mit `CREATE_BREAKAWAY_FROM_JOB` aus der Arbeitsgruppe.
|
||||||
|
|
||||||
|
⚠️ Der Rueckfall ohne die Fahne ist Pflicht, nicht Vorsicht: Steckt Rippy
|
||||||
|
in einer FREMDEN Arbeitsgruppe, die kein Herausloesen erlaubt, verweigert
|
||||||
|
Windows den Start rundweg (Zugriff verweigert). Ein Fenster, das gar nicht
|
||||||
|
mehr aufgeht, waere schlimmer als eines, das mitstirbt.
|
||||||
|
"""
|
||||||
|
fahnen = kw.pop("creationflags", 0)
|
||||||
|
try:
|
||||||
|
return subprocess.Popen(
|
||||||
|
befehl, creationflags=fahnen | CREATE_BREAKAWAY_FROM_JOB, **kw)
|
||||||
|
except OSError:
|
||||||
|
return subprocess.Popen(befehl, creationflags=fahnen, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def kernel32():
|
||||||
|
"""kernel32 mit vollstaendig angemeldeten Signaturen.
|
||||||
|
|
||||||
|
## Warum die `argtypes` hier keine Formsache sind (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Ohne sie reicht ctypes einen Python-Integer als **32-Bit-int** weiter.
|
||||||
|
`GetCurrentProcess()` liefert aber den Pseudogriff `(HANDLE)-1`, also
|
||||||
|
0xFFFFFFFFFFFFFFFF — und ctypes wirft beim Weiterreichen
|
||||||
|
|
||||||
|
ArgumentError: int too long to convert
|
||||||
|
|
||||||
|
Der erste Anlauf fing das mit einem breiten `except` ab und meldete
|
||||||
|
schlicht „ging nicht". Gemessen: alle drei Aufrufe gelingen, sobald die
|
||||||
|
Signaturen stehen. Ein Griff ist 64 Bit breit; das muss man Windows sagen.
|
||||||
|
"""
|
||||||
|
global _K32
|
||||||
|
if _K32 is not None:
|
||||||
|
return _K32
|
||||||
|
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
k.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||||
|
k.CreateJobObjectW.restype = wintypes.HANDLE
|
||||||
|
k.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int,
|
||||||
|
ctypes.c_void_p, wintypes.DWORD]
|
||||||
|
k.SetInformationJobObject.restype = wintypes.BOOL
|
||||||
|
k.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||||
|
k.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||||
|
k.GetCurrentProcess.argtypes = []
|
||||||
|
k.GetCurrentProcess.restype = wintypes.HANDLE
|
||||||
|
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
k.CloseHandle.restype = wintypes.BOOL
|
||||||
|
k.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
||||||
|
k.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
||||||
|
k.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
||||||
|
k.Process32FirstW.restype = wintypes.BOOL
|
||||||
|
k.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
||||||
|
k.Process32NextW.restype = wintypes.BOOL
|
||||||
|
k.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||||
|
k.OpenProcess.restype = wintypes.HANDLE
|
||||||
|
k.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||||
|
k.TerminateProcess.restype = wintypes.BOOL
|
||||||
|
_K32 = k
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def kinder_an_die_leine() -> bool:
|
||||||
|
"""Bindet alle kuenftigen Kindprozesse an das Leben dieses Prozesses.
|
||||||
|
|
||||||
|
Stirbt Rippy — geordnet, abgestuerzt oder per Taskmanager —, sterben
|
||||||
|
makemkvcon, HandBrake und flac mit. Das Laufwerk ist danach frei.
|
||||||
|
|
||||||
|
Einmal beim Start aufrufen. Wirft nie; ein Rippy ohne Arbeitsgruppe laeuft
|
||||||
|
wie bisher weiter, nur ohne diesen Schutz.
|
||||||
|
|
||||||
|
`BREAKAWAY_OK` ist Absicht: Der Dienst startet das Fensterprogramm und das
|
||||||
|
Aufraeum-Skript der Deinstallation. Beide sollen Rippy ueberleben und
|
||||||
|
loesen sich mit `CREATE_BREAKAWAY_FROM_JOB` heraus.
|
||||||
|
"""
|
||||||
|
global _ARBEITSGRUPPE
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
if _ARBEITSGRUPPE is not None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
k32 = kernel32()
|
||||||
|
gruppe = k32.CreateJobObjectW(None, None)
|
||||||
|
if not gruppe:
|
||||||
|
return False
|
||||||
|
grenzen = _ERWEITERTE_GRENZEN()
|
||||||
|
grenzen.BasicLimitInformation.LimitFlags = (
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK)
|
||||||
|
if not k32.SetInformationJobObject(
|
||||||
|
gruppe, JOB_OBJECT_ERWEITERTE_GRENZEN,
|
||||||
|
ctypes.byref(grenzen), ctypes.sizeof(grenzen)):
|
||||||
|
k32.CloseHandle(gruppe)
|
||||||
|
return False
|
||||||
|
if not k32.AssignProcessToJobObject(gruppe, k32.GetCurrentProcess()):
|
||||||
|
# Steckt Rippy schon in einer fremden Gruppe, die kein Herausloesen
|
||||||
|
# erlaubt, geht das nicht. Kein Grund zu scheitern — nur kein Schutz.
|
||||||
|
k32.CloseHandle(gruppe)
|
||||||
|
return False
|
||||||
|
_ARBEITSGRUPPE = gruppe # Griff offenhalten, siehe oben
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def prozessliste() -> list:
|
||||||
|
"""[(pid, eltern_pid, name), ...] aller laufenden Prozesse. Leer bei Fehlern."""
|
||||||
|
if os.name != "nt":
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
k32 = kernel32()
|
||||||
|
schnappschuss = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||||
|
if not schnappschuss or schnappschuss == wintypes.HANDLE(-1).value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
eintrag = _PROZESSEINTRAG()
|
||||||
|
eintrag.dwSize = ctypes.sizeof(_PROZESSEINTRAG)
|
||||||
|
liste = []
|
||||||
|
weiter = k32.Process32FirstW(schnappschuss, ctypes.byref(eintrag))
|
||||||
|
while weiter:
|
||||||
|
liste.append((eintrag.th32ProcessID,
|
||||||
|
eintrag.th32ParentProcessID,
|
||||||
|
eintrag.szExeFile))
|
||||||
|
weiter = k32.Process32NextW(schnappschuss, ctypes.byref(eintrag))
|
||||||
|
return liste
|
||||||
|
finally:
|
||||||
|
k32.CloseHandle(schnappschuss)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def waisen_finden(liste=None, namen=WERKZEUG_PROZESSE) -> list:
|
||||||
|
"""Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt.
|
||||||
|
|
||||||
|
Nur elternlose: Ein makemkvcon, das gerade zu einem laufenden Rippy
|
||||||
|
gehoert, hat einen lebenden Elternprozess und bleibt unangetastet. Das
|
||||||
|
gilt auch fuer ein zweites Rippy und fuer einen Aufruf von Hand.
|
||||||
|
"""
|
||||||
|
liste = prozessliste() if liste is None else liste
|
||||||
|
lebende = {pid for pid, _, _ in liste}
|
||||||
|
return [(pid, name) for pid, eltern, name in liste
|
||||||
|
if (name or "").lower() in namen and eltern not in lebende]
|
||||||
|
|
||||||
|
|
||||||
|
def waisen_beenden(liste=None, toeten=None) -> list:
|
||||||
|
"""Beendet elternlose Werkzeug-Prozesse. Gibt ihre Namen zurueck.
|
||||||
|
|
||||||
|
## Warum das beim Start passieren muss
|
||||||
|
|
||||||
|
Eine Waise haelt das Laufwerk fest — der naechste Rip scheitert dann mit
|
||||||
|
„Das Oeffnen der Disk schlug fehl", und niemand kann sich das erklaeren.
|
||||||
|
`kinder_an_die_leine` verhindert neue Waisen; diese hier raeumt weg, was
|
||||||
|
eine aeltere Fassung oder ein Absturz hinterlassen hat.
|
||||||
|
"""
|
||||||
|
if toeten is None:
|
||||||
|
def toeten(pid):
|
||||||
|
k32 = kernel32()
|
||||||
|
griff = k32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
||||||
|
if not griff:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(k32.TerminateProcess(griff, 1))
|
||||||
|
finally:
|
||||||
|
k32.CloseHandle(griff)
|
||||||
|
|
||||||
|
beendet = []
|
||||||
|
for pid, name in waisen_finden(liste):
|
||||||
|
try:
|
||||||
|
if toeten(pid):
|
||||||
|
beendet.append(name)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return beendet
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_konsole() -> bool:
|
||||||
|
"""Läuft dieser Prozess ohne Standardausgabe?
|
||||||
|
|
||||||
|
Das ist der Normalfall in einer `--windowed` gebauten EXE. `print()` würde
|
||||||
|
dann werfen oder ins Leere gehen — beides schlecht, deshalb wird es
|
||||||
|
abgefragt statt angenommen.
|
||||||
|
"""
|
||||||
|
return sys.stdout is None or getattr(sys.stdout, "fileno", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def an_elternkonsole_haengen() -> bool:
|
||||||
|
"""An die Konsole des Aufrufers andocken, falls es eine gibt.
|
||||||
|
|
||||||
|
Wer `Rippy.exe --status` in PowerShell eintippt, soll die Antwort DORT
|
||||||
|
sehen. Ein Fenster-Programm hat zwar keine eigene Konsole, darf sich aber
|
||||||
|
an die des Aufrufers hängen — genau dafür gibt es
|
||||||
|
`ATTACH_PARENT_PROCESS`. Beim Doppelklick gibt es keine, dann ist das
|
||||||
|
Ergebnis schlicht False und der Aufrufer leitet um.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
try:
|
||||||
|
return bool(ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
.AttachConsole(ATTACH_PARENT_PROCESS))
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def protokolldatei(basis: str = None) -> str:
|
||||||
|
"""Wohin die Ausgabe geht, wenn niemand zusieht. (reine Funktion)
|
||||||
|
|
||||||
|
Neben die Datenbank, nicht ins TEMP: Was Rippy über sich selbst
|
||||||
|
aufschreibt, gehört zu Rippy und wird beim Deinstallieren mit entfernt.
|
||||||
|
"""
|
||||||
|
wurzel = basis or os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.join(wurzel, "Rippy", "rippy.log")
|
||||||
|
|
||||||
|
|
||||||
|
def ausgabe_umleiten(pfad: str = None) -> str:
|
||||||
|
"""Standardausgabe in die Protokolldatei. Gibt den Pfad zurück, sonst "".
|
||||||
|
|
||||||
|
OHNE das würde jede Bibliothek, die auf `sys.stderr` schreibt, in einer
|
||||||
|
`--windowed` EXE mit `AttributeError: 'NoneType' object has no attribute
|
||||||
|
'write'` sterben — mitten im Start, ohne dass irgendwo etwas stünde.
|
||||||
|
"""
|
||||||
|
pfad = pfad or protokolldatei()
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||||
|
strom = open(pfad, "a", encoding="utf-8", errors="replace", buffering=1)
|
||||||
|
except OSError:
|
||||||
|
# Selbst das ging nicht. Dann lieber ins Nichts als beim Start sterben.
|
||||||
|
try:
|
||||||
|
strom = open(os.devnull, "w")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
sys.stdout = strom
|
||||||
|
sys.stderr = strom
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def mit_rechteabfrage_starten(programm: str, argumente: str = "") -> bool:
|
||||||
|
r"""Startet ein Programm über die Shell — mit UAC-Abfrage, falls nötig.
|
||||||
|
|
||||||
|
Der Unterschied zu `subprocess.Popen` ist der ganze Punkt: `Popen` benutzt
|
||||||
|
`CreateProcess`, und das **zeigt keine UAC-Abfrage**. Verlangt das Ziel im
|
||||||
|
Manifest Administratorrechte, bricht es mit `ERROR_ELEVATION_REQUIRED`
|
||||||
|
(740) ab. Genau daran ist am 28.08.2026 der MakeMKV-Installer gescheitert.
|
||||||
|
|
||||||
|
Ohne Verb: Windows entscheidet selbst, ob eine Abfrage nötig ist. Ein
|
||||||
|
festes „runas" würde auch dort erhöhen, wo es nicht gebraucht wird.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
SW_SHOWNORMAL = 1
|
||||||
|
try:
|
||||||
|
ergebnis = ctypes.WinDLL("shell32").ShellExecuteW(
|
||||||
|
None, None, programm, argumente or None, None, SW_SHOWNORMAL)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return False
|
||||||
|
# Über 32 heißt Erfolg (dokumentierter Rückgabewert von ShellExecute).
|
||||||
|
# 5 = ACCESS_DENIED: Der Nutzer hat die UAC-Abfrage weggeklickt.
|
||||||
|
return int(ergebnis) > 32
|
||||||
|
|
||||||
|
|
||||||
|
def als_admin_neu_starten(programm: str, argumente: str = "") -> bool:
|
||||||
|
r"""Startet ein Programm mit Administratorrechten neu (löst UAC aus).
|
||||||
|
|
||||||
|
True heißt: Windows hat den Start angenommen. False heißt: Der Nutzer hat
|
||||||
|
die Abfrage abgebrochen oder es ging nicht — dann läuft der aufrufende
|
||||||
|
Prozess einfach weiter, statt sich zu beenden.
|
||||||
|
|
||||||
|
## Warum das NICHT der Normalfall ist (gemessen 28.08.2026)
|
||||||
|
|
||||||
|
Ein erhöhter Prozess bekommt ein anderes Zugriffstoken, und eingebundene
|
||||||
|
Netzlaufwerke hängen am Token der SITZUNG. Auf diesem Rechner gemessen:
|
||||||
|
|
||||||
|
ohne Erhöhung X:, Y:, Z: sichtbar
|
||||||
|
EnableLinkedConnections nicht gesetzt (Windows-Standard)
|
||||||
|
|
||||||
|
Erhöht wären die drei also weg — genau der Befund, den der Commander
|
||||||
|
gemeldet hat („als Admin kann man kein netzlaufwerk wählen"). Deshalb ist
|
||||||
|
das hier ein Knopf und keine Voreinstellung: Wer nach `Programme`
|
||||||
|
installieren will, braucht ihn; wer eine Freigabe als Ablage will, darf
|
||||||
|
ihn NICHT drücken.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
SW_SHOWNORMAL = 1
|
||||||
|
try:
|
||||||
|
# ShellExecuteW mit dem Verb „runas" ist der dokumentierte Weg. Ein
|
||||||
|
# Rückgabewert über 32 heißt Erfolg; 5 (ACCESS_DENIED) heißt: Der
|
||||||
|
# Nutzer hat die UAC-Abfrage weggeklickt.
|
||||||
|
ergebnis = ctypes.WinDLL("shell32").ShellExecuteW(
|
||||||
|
None, "runas", programm, argumente or None, None, SW_SHOWNORMAL)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return False
|
||||||
|
return int(ergebnis) > 32
|
||||||
|
|
||||||
|
|
||||||
|
def meldung_zeigen(text: str, titel: str = "Rippy", fehler: bool = False) -> bool:
|
||||||
|
"""Ein Meldungsfenster. True, wenn es wirklich gezeigt wurde.
|
||||||
|
|
||||||
|
Für alles, was der Nutzer sehen MUSS und ohne Konsole sonst nie sähe —
|
||||||
|
vor allem das Ergebnis der Installation.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
symbol = MB_ICONERROR if fehler else MB_ICONINFORMATION
|
||||||
|
try:
|
||||||
|
ctypes.WinDLL("user32", use_last_error=True).MessageBoxW(
|
||||||
|
None, str(text), str(titel), MB_OK | symbol)
|
||||||
|
return True
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Die vier Ports — die Nahtstellen, an denen Rippy austauschbar wird.
|
||||||
|
|
||||||
|
## Wozu das gut ist (KONZEPT-V2.md § 1 und § 2.3)
|
||||||
|
|
||||||
|
Rippy v2 soll in drei Betriebsarten laufen: Docker, native Windows-App,
|
||||||
|
Headless-Linux-Dienst. Der Trick dabei ist, dass es NICHT drei Programme sind,
|
||||||
|
sondern eines — und ein Betriebsmodus nur die Auswahl der Treiber hinter diesen
|
||||||
|
vier Nahtstellen:
|
||||||
|
|
||||||
|
Port Standalone Verteilt
|
||||||
|
──────── ─────────────────────────────── ──────────────────────────
|
||||||
|
Store SQLite (WAL) PostgreSQL
|
||||||
|
Queue LocalQueue (Tabelle + Pool) Celery über Redis
|
||||||
|
Bus In-Process (asyncio) Redis Pub/Sub
|
||||||
|
Drives LinuxDrives / WindowsDrives LinuxDrives je Knoten
|
||||||
|
|
||||||
|
## Warum Protocol und nicht Basisklasse
|
||||||
|
|
||||||
|
Ein `typing.Protocol` prüft die FORM, nicht die Abstammung. Ein Treiber muss
|
||||||
|
nichts erben und nichts importieren — er muss nur die Methoden haben. Damit
|
||||||
|
lässt sich in Tests ein handgeschriebenes Fake-Objekt einsetzen, ohne
|
||||||
|
Mocking-Bibliothek. Genau so arbeitet `zombies.raeume_zombies_auf(celery, db)`
|
||||||
|
heute schon: Die Tests dort geben ein `FakeDb` hinein, und es funktioniert,
|
||||||
|
weil nur die Form zählt. Diese Datei schreibt die Form auf, die bisher nur
|
||||||
|
stillschweigend galt.
|
||||||
|
|
||||||
|
## Stand (Etappe V2-1, 28.08.2026)
|
||||||
|
|
||||||
|
Beschrieben sind alle vier Ports. Treiber gibt es bisher für:
|
||||||
|
|
||||||
|
Store -> rippy.store (SQLAlchemy Core; heute Postgres,
|
||||||
|
ab V2-2 auch SQLite — derselbe Code)
|
||||||
|
Drives -> rippy.drives.linux (ioctl; Windows folgt in V2-4)
|
||||||
|
Queue -> Celery, noch ohne Adapter (V2-2)
|
||||||
|
Bus -> noch keiner (V2-3)
|
||||||
|
|
||||||
|
Die Protokolle stehen trotzdem schon hier: Sie sind der Vertrag, gegen den die
|
||||||
|
nächsten Etappen gebaut werden, und sie machen sichtbar, wo heute noch etwas
|
||||||
|
fehlt. Ein Port ohne zweiten Treiber ist keine Abstraktion auf Vorrat — er ist
|
||||||
|
die Stelle, an der der zweite Treiber hinkommt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import AsyncIterator, Iterable, Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────── Store ────
|
||||||
|
@runtime_checkable
|
||||||
|
class Store(Protocol):
|
||||||
|
"""Zustand: Jobs, Logs, Einstellungen, Worker, Speicherziele.
|
||||||
|
|
||||||
|
WICHTIG (KONZEPT-V2.md § 3.1): Der Store ist die WAHRHEIT über den
|
||||||
|
Job-Zustand — nicht der Broker. Daraus folgt alles Weitere: Ein Auftrag
|
||||||
|
gehört dem, der ihn per bedingtem UPDATE übernommen hat, und er ist wieder
|
||||||
|
frei, wenn dessen Lease abläuft. Ein abgestürzter Knoten hinterlässt damit
|
||||||
|
keine Leiche, die jemand einsammeln muss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def init_db(self) -> None: ...
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> Optional[dict]: ...
|
||||||
|
def list_jobs(self, limit: int = 100) -> list: ...
|
||||||
|
def insert_job(self, job_id: str, device: str, **felder) -> None: ...
|
||||||
|
def update_job(self, job_id: str, **felder) -> None: ...
|
||||||
|
def delete_job(self, job_id: str) -> None: ...
|
||||||
|
def get_job_status(self, job_id: str) -> str: ...
|
||||||
|
def list_jobs_mit_status(self, stati: Iterable[str]) -> list: ...
|
||||||
|
|
||||||
|
def add_log(self, level: str, source: str, message: str) -> None: ...
|
||||||
|
def list_logs(self, limit: int = 200) -> list: ...
|
||||||
|
|
||||||
|
def get_settings(self, key: str = "ui") -> dict: ...
|
||||||
|
def save_settings(self, werte: dict, key: str = "ui") -> None: ...
|
||||||
|
|
||||||
|
def save_worker(self, name: str, encoder_liste: list, info: dict = None) -> None: ...
|
||||||
|
def list_workers(self) -> list: ...
|
||||||
|
def zaehle_online_worker(self, sekunden: int = 120) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────── Queue ────
|
||||||
|
@runtime_checkable
|
||||||
|
class Queue(Protocol):
|
||||||
|
"""Aufträge: einreihen, übernehmen, am Leben halten, abschließen.
|
||||||
|
|
||||||
|
`uebernehmen` gibt einem Knoten einen Auftrag NUR, wenn er die verlangten
|
||||||
|
Fähigkeiten hat (`{"drive": "sr0"}` bzw. `{"encoder": "nvenc"}`) — das ist
|
||||||
|
dieselbe Auswahl, die v1 über getrennte Celery-Queues und `worker_direct`
|
||||||
|
gelöst hat, nur als Datum statt als Verdrahtung.
|
||||||
|
|
||||||
|
`lebenszeichen` ist der Kern: Wer einen Auftrag hält, verlängert alle paar
|
||||||
|
Sekunden seine Lease. Läuft sie ab, ist der Auftrag frei — egal ob der
|
||||||
|
Knoten abgestürzt ist, das Netz weg war oder jemand den Stecker gezogen
|
||||||
|
hat. Das ersetzt die nachträgliche Zombie-Erkennung aus v1 durch einen
|
||||||
|
Mechanismus, der den Fall gar nicht erst entstehen lässt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def einreihen(self, auftrag: dict) -> str: ...
|
||||||
|
def uebernehmen(self, faehigkeiten: set, knoten: str) -> Optional[dict]: ...
|
||||||
|
def lebenszeichen(self, auftrag_id: str) -> None: ...
|
||||||
|
def abschliessen(self, auftrag_id: str, ergebnis: dict) -> None: ...
|
||||||
|
def fehlgeschlagen(self, auftrag_id: str, fehler: str) -> None: ...
|
||||||
|
def abbrechen(self, auftrag_id: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────── Bus ────
|
||||||
|
@runtime_checkable
|
||||||
|
class Bus(Protocol):
|
||||||
|
"""Ereignisse — Feuer und vergiss.
|
||||||
|
|
||||||
|
⚠️ Der Bus überträgt nur NACHRICHTEN ÜBER ÄNDERUNGEN, nie den Zustand
|
||||||
|
selbst. Wer den Zustand will, fragt den Store.
|
||||||
|
|
||||||
|
Der Grund steht in AGENTS.md: „Ein verpasster Abruf ist keine Nachricht
|
||||||
|
über die Welt." Im UI stand fünfmal `catch(() => [])` — jeder
|
||||||
|
fehlgeschlagene Abruf hieß damit „es gibt keine Jobs". Wenn der Bus keinen
|
||||||
|
Zustand trägt, kann ein verpasstes Ereignis auch keinen Zustand löschen.
|
||||||
|
|
||||||
|
`abonnieren(ab_seq=…)` liefert alles ab einer Folgenummer nach. Ist die
|
||||||
|
Lücke zu groß, schickt der Treiber ausdrücklich ein `snapshot`-Ereignis
|
||||||
|
statt stillschweigend Deltas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def senden(self, ereignis: dict) -> None: ...
|
||||||
|
def abonnieren(self, ab_seq: Optional[int] = None) -> AsyncIterator[dict]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────── Drives ────
|
||||||
|
@runtime_checkable
|
||||||
|
class Drives(Protocol):
|
||||||
|
"""Hardware: Laufwerke finden, Zustand lesen, verriegeln, auswerfen.
|
||||||
|
|
||||||
|
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen.
|
||||||
|
|
||||||
|
Zwei Regeln gelten für jeden Treiber (KONZEPT-V2.md § 5):
|
||||||
|
|
||||||
|
1. **Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** In dieser
|
||||||
|
Reihenfolge, auf jeder Plattform. Am 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 und entriegelt sie nicht wieder.
|
||||||
|
2. **Jede Operation mit Zeitgrenze, nie im Ereignis-Loop.** Ein hängendes
|
||||||
|
ioctl auf einem defekten Laufwerk darf die API nicht blockieren —
|
||||||
|
dieselbe Fehlerklasse wie die CIFS-Blockade aus v1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def laufwerke(self) -> list: ...
|
||||||
|
def zustand(self, geraet: str) -> str: ...
|
||||||
|
def info(self, geraet: str) -> dict: ...
|
||||||
|
def verriegeln(self, geraet: str, an: bool) -> None: ...
|
||||||
|
def auswerfen(self, geraet: str) -> None: ...
|
||||||
|
async def ereignisse(self) -> AsyncIterator[dict]: ...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Store", "Queue", "Bus", "Drives"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user