Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b | ||
|
|
6881a63fb7 | ||
|
|
35370fd552 | ||
|
|
11001a443e | ||
|
|
be3fac53af | ||
|
|
878b24c43b | ||
|
|
ea8a043643 | ||
|
|
95beb91364 | ||
|
|
480b658294 | ||
|
|
16df61ea3a | ||
|
|
3d5f82b786 | ||
|
|
8e65411189 | ||
|
|
082e8b8b55 | ||
|
|
6dfff5c99d | ||
|
|
8eca982e68 | ||
|
|
151376aee3 | ||
|
|
b47d21510d | ||
|
|
a4a045f303 | ||
|
|
66642b8d93 | ||
|
|
a5e64f8e56 | ||
|
|
ce4e7e6b8a | ||
|
|
60c0f93237 | ||
|
|
e1984dc543 | ||
|
|
ca212ff835 | ||
|
|
bfc0cada62 | ||
|
|
e1577a4f47 | ||
|
|
c01ef1081c | ||
|
|
069fc46f44 | ||
|
|
99c05865af | ||
|
|
f4a8d77598 | ||
|
|
288f9eeb8d | ||
|
|
059183c651 | ||
|
|
e5254c23f0 | ||
|
|
9d95c95d79 | ||
|
|
3d7f3b1680 | ||
|
|
b723fee3a2 | ||
|
|
4337253797 | ||
|
|
05ab655116 | ||
|
|
95705c8d88 | ||
|
|
f097a0b59d | ||
|
|
1fbe2cfc55 | ||
|
|
55db9eb13f | ||
|
|
d2645f87e8 | ||
|
|
7558be4854 | ||
|
|
dd1d0b7365 | ||
|
|
edfe0d4313 |
@@ -29,11 +29,21 @@ Thumbs.db
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
.docker/
|
.docker/
|
||||||
|
|
||||||
# Test
|
# Test — ** ist Pflicht, sonst greifen die Muster NUR in der obersten Ebene
|
||||||
|
# des Bau-Kontexts. Ohne ** landeten am 28.08.2026 dreissig test_*.py in beiden
|
||||||
|
# Laufzeit-Images (im laufenden Container nachgezaehlt: find /app -name
|
||||||
|
# "test_*.py" | wc -l -> 30). Schadet nichts, gehoert aber nicht ins Image.
|
||||||
tests/
|
tests/
|
||||||
|
**/tests/
|
||||||
test_*.py
|
test_*.py
|
||||||
|
**/test_*.py
|
||||||
*_test.py
|
*_test.py
|
||||||
|
**/*_test.py
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/__pycache__/
|
||||||
|
conftest.py
|
||||||
|
**/conftest.py
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ jobs:
|
|||||||
|
|
||||||
if [ -n "$pkg_dateien" ]; then
|
if [ -n "$pkg_dateien" ]; then
|
||||||
echo "== Node/TypeScript erkannt =="
|
echo "== Node/TypeScript erkannt =="
|
||||||
|
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||||
|
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||||
|
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||||
|
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||||
|
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||||
|
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||||
|
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||||
|
if [ "$node_major" -lt 22 ]; then
|
||||||
|
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
while IFS= read -r pkg; do
|
while IFS= read -r pkg; do
|
||||||
[ -n "$pkg" ] || continue
|
[ -n "$pkg" ] || continue
|
||||||
d=$(dirname "$pkg")
|
d=$(dirname "$pkg")
|
||||||
|
|||||||
@@ -54,3 +54,7 @@ logs/
|
|||||||
# Data
|
# Data
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||||
|
design-entwuerfe/
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
|||||||
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||||
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
|
||||||
|
|
||||||
|
### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben
|
||||||
|
|
||||||
|
**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.**
|
||||||
|
Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade
|
||||||
|
ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint
|
||||||
|
dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch
|
||||||
|
ist. An einem Tag vier Mal passiert.
|
||||||
|
|
||||||
|
falsch: "einen Ordner unter „Benutzer" wählen"
|
||||||
|
richtig: 'einen Ordner unter „Benutzer" wählen'
|
||||||
|
|
||||||
|
**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE
|
||||||
|
Anführungszeichen.**
|
||||||
|
|
||||||
|
**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein,
|
||||||
|
halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`,
|
||||||
|
und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert,
|
||||||
|
zuletzt beim Schreiben genau dieses Absatzes.
|
||||||
|
|
||||||
|
**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug
|
||||||
|
schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über
|
||||||
|
`chr(92)` zusammensetzen.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
||||||
|
|||||||
@@ -305,12 +305,26 @@ Was wirklich angefasst werden muss:
|
|||||||
|
|
||||||
| Thema | v1 | v2 |
|
| Thema | v1 | v2 |
|
||||||
|-------|-----|-----|
|
|-------|-----|-----|
|
||||||
| Migrationen | `ALTER TABLE … ADD COLUMN IF NOT EXISTS` von Hand in `init_db()` — **Postgres-only, bricht auf SQLite** | Alembic, ein Verzeichnis, beide Dialekte |
|
| Migrationen | `ALTER TABLE … ADD COLUMN IF NOT EXISTS` von Hand in `init_db()` — **Postgres-only, bricht auf SQLite** | dialektneutral: erst `inspect()` fragen, dann nur fehlende Spalten anlegen — **kein Alembic**, siehe Kasten unten |
|
||||||
| Nebenläufigkeit | Postgres regelt es | SQLite: `PRAGMA journal_mode=WAL`, `busy_timeout=5000`, **ein** Schreiber-Kontext |
|
| Nebenläufigkeit | Postgres regelt es | SQLite: `PRAGMA journal_mode=WAL`, `busy_timeout=5000`, **ein** Schreiber-Kontext |
|
||||||
| Zeitstempel | `DateTime(timezone=True)` | unverändert; SQLite speichert ISO-8601 UTC |
|
| Zeitstempel | `DateTime(timezone=True)` | unverändert; SQLite speichert ISO-8601 UTC |
|
||||||
| JSON-Felder | `Text` + `json.dumps` von Hand | unverändert (portabel), Serialisierung in den Store gezogen |
|
| JSON-Felder | `Text` + `json.dumps` von Hand | unverändert (portabel), Serialisierung in den Store gezogen |
|
||||||
| Duplikat | `api/db.py` **und** `worker/db.py` | eine Datei |
|
| Duplikat | `api/db.py` **und** `worker/db.py` | eine Datei |
|
||||||
|
|
||||||
|
> **Abweichung vom Entwurf (beim Bauen entschieden, 28.08.2026):** Hier stand
|
||||||
|
> ursprünglich Alembic. Zwei Dinge sprachen beim Umsetzen dagegen. Erstens
|
||||||
|
> **gibt es die Datenbank auf der VM schon** — Alembic müsste sie erst
|
||||||
|
> „stempeln" (`alembic stamp head`), sonst hält es sie für leer und versucht
|
||||||
|
> vorhandene Tabellen anzulegen; das ist ein Handgriff auf einer laufenden
|
||||||
|
> Installation und damit genau die Sorte Schritt, die beim nächsten Deploy
|
||||||
|
> jemand vergisst. Zweitens **gibt es hier nichts zu versionieren**: Die
|
||||||
|
> gesamte Migrationslast des Projekts sind drei nachgetragene Spalten.
|
||||||
|
> Stattdessen fragt `store.migrieren()` per `inspect()` nach, welche Spalten
|
||||||
|
> existieren, und legt nur die fehlenden an — das läuft auf beiden Dialekten.
|
||||||
|
> Sollte das Schema je wirklich wandern (Spalten umbenennen, Daten
|
||||||
|
> umschichten), ist Alembic die richtige Antwort, dann aber mit einer
|
||||||
|
> bewussten Stempel-Runde.
|
||||||
|
|
||||||
**SQLite-Grenzen, ehrlich benannt:** WAL erlaubt viele Leser und *einen*
|
**SQLite-Grenzen, ehrlich benannt:** WAL erlaubt viele Leser und *einen*
|
||||||
Schreiber. Für Rippy reicht das mit großem Abstand — ein Rip schreibt etwa alle
|
Schreiber. Für Rippy reicht das mit großem Abstand — ein Rip schreibt etwa alle
|
||||||
2 s einen Fortschrittswert. Wer mehr als ~4 gleichzeitige Rip-Knoten fährt,
|
2 s einen Fortschrittswert. Wer mehr als ~4 gleichzeitige Rip-Knoten fährt,
|
||||||
@@ -1253,8 +1267,207 @@ Eingabemaske — nur der Knopf „Verbinden" wird zu „Zeile kopieren".
|
|||||||
`apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in
|
`apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in
|
||||||
V2-5 ersatzlos weg.
|
V2-5 ersatzlos weg.
|
||||||
|
|
||||||
|
### Entscheid 4 — Echtes Fenster statt Browser-Tab, aber ohne Electron
|
||||||
|
|
||||||
|
Nachgefragt vom Commander am 28.08.2026:
|
||||||
|
|
||||||
|
> „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir kein
|
||||||
|
> Electron oder sowas und machen daraus einen echten Client?"
|
||||||
|
|
||||||
|
Die erste Hälfte trifft zu und wird umgesetzt: Ein Browser-Tab ist kein Client.
|
||||||
|
Er hat eine Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat
|
||||||
|
kein eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er
|
||||||
|
habe Rippy beendet.
|
||||||
|
|
||||||
|
Die zweite Hälfte wird **abgelehnt**, und zwar gemessen statt geschätzt:
|
||||||
|
|
||||||
|
| Weg | Zusatz zum Paket | Was mitkommt |
|
||||||
|
|-----|------------------|--------------|
|
||||||
|
| **pywebview + WebView2** | **8,0 MB** | nur die Anbindung; der Renderer liegt schon auf dem System |
|
||||||
|
| Electron | ~150–210 MB | ein komplettes zweites Chromium **und** eine zweite Laufzeitumgebung neben Python |
|
||||||
|
|
||||||
|
Windows 11 liefert die WebView2-Laufzeit mit. Auf dem Rechner des Commanders am
|
||||||
|
28.08.2026 nachgesehen: **151.0.4129.107**. Und nachgemessen, was sie wirklich
|
||||||
|
rendert — nicht angenommen:
|
||||||
|
|
||||||
|
```
|
||||||
|
navigator.userAgent …Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
|
||||||
|
fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also
|
||||||
|
denselben Renderer, nur ohne ihn ein zweites Mal mitzuschleppen. Damit bleibt
|
||||||
|
auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an.
|
||||||
|
|
||||||
|
**Drei Dinge gehören zum Entscheid, nicht als Beiwerk:**
|
||||||
|
|
||||||
|
1. **Kein stiller Rückfall auf MSHTML.** `pywebview` kann unter Windows auch den
|
||||||
|
alten IE-Renderer nehmen; der stellt die React-Oberfläche nicht dar. Fehlt
|
||||||
|
die WebView2-Laufzeit, öffnet Rippy **den Browser** und sagt warum — ein
|
||||||
|
leeres Fenster wäre schlimmer als ein Tab.
|
||||||
|
2. **Fenster und Tray sind getrennte Prozesse.** `pystray` belegt unter Windows
|
||||||
|
den Haupt-Thread, das WebView2-Fenster braucht ihn genauso; zwei
|
||||||
|
Nachrichtenschleifen passen nicht in einen Thread. `--dienst` hält Server und
|
||||||
|
Tray, `--oeffnen` ist der Client davor. Beide heißen im Taskmanager
|
||||||
|
`Rippy.exe`, und das Fenster lässt sich schließen, ohne den Dienst
|
||||||
|
mitzureißen.
|
||||||
|
3. **Die eigene Konsole wird versteckt, eine geerbte nie.** `Rippy.exe` ist ein
|
||||||
|
Konsolenprogramm, weil der Installer seine Meldungen zeigen muss. Beim
|
||||||
|
Doppelklick auf das Desktop-Symbol blitzte damit erst eine schwarze Box auf.
|
||||||
|
`GetConsoleProcessList` unterscheidet beides: genau ein Prozess an der
|
||||||
|
Konsole heißt, sie gehört uns.
|
||||||
|
|
||||||
|
**Folge für den Plan:** Kein neuer Etappen-Punkt — das ist Teil von V2-4 und
|
||||||
|
dort erledigt. `src/rippy/fenster.py` trägt die vollständige Begründung.
|
||||||
|
|
||||||
|
### Entscheid 5 — Die Werkzeuge gehören ins Setup, nicht in eine Fehlermeldung
|
||||||
|
|
||||||
|
Der Commander am 28.08.2026:
|
||||||
|
|
||||||
|
> „handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups
|
||||||
|
> separat installiert werden! Ohne das ist das tool NICHT einsatzfähig"
|
||||||
|
|
||||||
|
Er hat recht, und die Lücke war real: `katalog.py` konnte die Werkzeuge
|
||||||
|
finden, `beschaffen.py` konnte sie holen — **das Setup rief beides nie auf.**
|
||||||
|
Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die
|
||||||
|
ihm mitteilte, was fehlt, und keinen Weg, es zu ändern.
|
||||||
|
|
||||||
|
Die beiden gehen unterschiedliche Wege, und der Grund ist die **Lizenz**,
|
||||||
|
nicht die Bequemlichkeit:
|
||||||
|
|
||||||
|
| | Weg | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| **HandBrakeCLI** | **mitgeliefert** (+35 MB) | GPL-2 erlaubt die Weitergabe ausdrücklich, solange Lizenztext und Quellverweis dabei sind. `LIZENZ-HandBrake.txt` liegt daneben. Damit komprimiert Rippy auch ohne Internet. |
|
||||||
|
| **MakeMKV** | **beim Einrichten geholt** | Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. Der Nutzer bezieht sie also weiterhin von MakeMKV; Rippy nimmt ihm nur die Handgriffe ab. |
|
||||||
|
|
||||||
|
Das deckt sich mit § 4.1 („MakeMKV wird NICHT mitgeliefert") und mit der
|
||||||
|
Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig.
|
||||||
|
|
||||||
|
**Drei Regeln, die zum Entscheid gehören:**
|
||||||
|
|
||||||
|
1. **Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt.**
|
||||||
|
`einrichten.sicherstellen()` liest den Bestand VOR und NACH dem Versuch
|
||||||
|
und gibt zurück, was danach wirklich da ist — nicht, dass es versucht
|
||||||
|
wurde.
|
||||||
|
2. **Ein nicht erreichbarer Download bricht die Installation nicht ab.** Am
|
||||||
|
28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem
|
||||||
|
installiert und sagt im Klartext, was fehlt und wie man es beschafft.
|
||||||
|
3. **Ohne HandBrake ist Rippy einsatzbereit, ohne MakeMKV nicht.** Ein Rip
|
||||||
|
läuft ohne Kompression durch — das ist ein vorgesehener Betriebsfall.
|
||||||
|
Ohne MakeMKV ist Rippy ein Anzeigeprogramm. Nur `pflicht`-Werkzeuge
|
||||||
|
entscheiden über „einsatzbereit".
|
||||||
|
|
||||||
|
**Nicht im Repo:** Die 70,9 MB HandBrakeCLI holt der **Bau** nach
|
||||||
|
`dist/windows/vendor/` (nicht versioniert) — dasselbe Muster wie der
|
||||||
|
`vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem
|
||||||
|
Klon dabei und bei jedem Update ein zweites Mal.
|
||||||
|
|
||||||
|
|
||||||
|
### Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt
|
||||||
|
|
||||||
|
Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt:
|
||||||
|
|
||||||
|
> „Du hast ja quasi nur rippy genommen und die docker installation für Windows
|
||||||
|
> gebaut. […] Das gilt für die ganze standalone version für Windows, auch für
|
||||||
|
> die settings und die Anleitung usw."
|
||||||
|
|
||||||
|
> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert —
|
||||||
|
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
|
||||||
|
> garnichts Rippy geht einfach auf."
|
||||||
|
|
||||||
|
#### 6a — Fähigkeiten statt Modus-Name
|
||||||
|
|
||||||
|
Im Windows-Fenster stand `Worker erreichbar: 0 von 1`, `Container-Platte:
|
||||||
|
unbekannt` und `Prüfen: docker compose ps`. Kein Satz davon ergibt dort einen
|
||||||
|
Sinn.
|
||||||
|
|
||||||
|
Die naheliegende Reparatur wäre ein `if (windows)` an dreißig Stellen gewesen
|
||||||
|
— dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es
|
||||||
|
fehlte etwas anderes: **Das UI hat nie erfahren, worauf es läuft.**
|
||||||
|
|
||||||
|
`GET /betrieb` meldet deshalb **Fähigkeiten**, keinen Namen:
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu
|
||||||
|
schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One
|
||||||
|
hat Container-Pfade, aber keinen zweiten Worker.
|
||||||
|
|
||||||
|
#### 6b — Ein Setup, das prüft und fragt
|
||||||
|
|
||||||
|
Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm **und** Ablage),
|
||||||
|
Port, drei Schalter. Drei Regeln:
|
||||||
|
|
||||||
|
1. **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist eine
|
||||||
|
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches
|
||||||
|
Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein
|
||||||
|
vorgesehener Betriebsfall.
|
||||||
|
2. **Jeder Befund sagt, was zu tun ist.** Ein Test hält das für jede Prüfung
|
||||||
|
fest.
|
||||||
|
3. **Die Prüfungen kennen kein Fenster.** Sie stehen in `einrichtung.py` und
|
||||||
|
sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau
|
||||||
|
und Brücke.
|
||||||
|
|
||||||
|
WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der
|
||||||
|
Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem
|
||||||
|
Netz; sie muss auf einem Rechner ohne Internet aufgehen.
|
||||||
|
|
||||||
|
|
||||||
|
#### 5a — Ausweichquellen für MakeMKV (28.08.2026, nachgetragen)
|
||||||
|
|
||||||
|
Der Commander: *„bitte baue für MakeMKV fallback seiten ein."* Anlass war ein
|
||||||
|
echter Ausfall. Gemessen:
|
||||||
|
|
||||||
|
https://www.makemkv.com/download/ HTTP 525 (dauerhaft)
|
||||||
|
https://forum.makemkv.com/ Zeitablauf / 522 / OK (wechselnd)
|
||||||
|
web.archive.org/web/2025id_/…exe HTTP 200, 16.432.607 Bytes
|
||||||
|
|
||||||
|
525/522 heißen: Cloudflare erreicht den Ursprungsserver nicht. Eine Störung
|
||||||
|
beim Hersteller, nichts, was Rippy reparieren könnte.
|
||||||
|
|
||||||
|
**Die Kette, in dieser Reihenfolge:**
|
||||||
|
|
||||||
|
| | Version | Datei |
|
||||||
|
|---|---|---|
|
||||||
|
| eigene Quelle (`basis`) | — | zuerst, wer sie einträgt hat sich etwas dabei gedacht |
|
||||||
|
| Hersteller-Seite | **maßgeblich** — wer sie erreicht, sucht nicht weiter | ja |
|
||||||
|
| Hersteller-Forum | ja (Ankündigungs-Bereich) | — |
|
||||||
|
| Internet Archive | ja (Rückfall) | ja |
|
||||||
|
|
||||||
|
Zwei Feinheiten, beide aus Messungen:
|
||||||
|
|
||||||
|
* **Die höchste Nummer gewinnt, nicht die erste.** Antwortet das Forum nicht,
|
||||||
|
meldet das Archiv einen älteren Stand (1.18.2 statt 1.18.4) — „neueste
|
||||||
|
Fassung" wäre dann eine falsche Aussage.
|
||||||
|
* **Ein zweiter Anlauf, mit knapper Zeitgrenze.** Drei Abrufe am Forum:
|
||||||
|
Zeitablauf, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in
|
||||||
|
zwei von drei Fällen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste
|
||||||
|
Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt: acht
|
||||||
|
Sekunden je Versuch, schlimmstenfalls gut zwanzig insgesamt.
|
||||||
|
|
||||||
|
**Und warum das trotzdem sicher ist.** Der naheliegende Schutz geht nicht:
|
||||||
|
MakeMKV **signiert seinen Installer nicht** (`Get-AuthenticodeSignature` →
|
||||||
|
`NotSigned`, an der echten Datei gemessen). Eine Signaturprüfung wäre eine,
|
||||||
|
die immer fehlschlägt. Geprüft wird stattdessen die Versions-Ressource:
|
||||||
|
|
||||||
|
CompanyName GuinpinSoft inc
|
||||||
|
FileDescription MakeMKV installer
|
||||||
|
FileVersion v1.18.4
|
||||||
|
|
||||||
|
Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die
|
||||||
|
Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine
|
||||||
|
Fehlerseite mit `.exe`-Namen, ein abgebrochener Download, eine falsche
|
||||||
|
Fassung. Und Rippy nennt hinterher die Quelle, aus der die Datei kam.
|
||||||
|
|
||||||
|
**Die Grenze aus `KONZEPT.md` § 6 bleibt unangetastet:** Rippy liefert
|
||||||
|
MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers — im
|
||||||
|
Rückfall aus einem Archiv, das genau diese Datei aufbewahrt.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
### Was jetzt noch fehlt, bevor gebaut wird
|
### Was jetzt noch fehlt, bevor gebaut wird
|
||||||
|
|
||||||
- **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom
|
- **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom
|
||||||
|
|||||||
@@ -619,15 +619,58 @@ Medien.
|
|||||||
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
plus Worker) UND ein Verbindungsabriss keine Liste leert.
|
||||||
|
|
||||||
### V2-4: Windows nativ
|
### V2-4: Windows nativ
|
||||||
- [ ] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
|
- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
|
||||||
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
|
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
|
||||||
MMC `GET CONFIGURATION` für den Disc-Typ)
|
MMC `GET CONFIGURATION` für den Disc-Typ)
|
||||||
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling
|
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
|
||||||
- [ ] Dienst (WinSW) + Tray als GETRENNTE Prozesse; WebView2-Fenster
|
vorerst über den Poll aus `drives/detection.py`
|
||||||
- [ ] Nuitka `--standalone` (one-dir, nicht one-file) + Inno Setup
|
- [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`
|
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
|
||||||
|
— **offen**
|
||||||
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
||||||
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
|
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
|
### V2-5: Docker neu
|
||||||
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||||
|
|
||||||
|
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||||
|
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||||
|
Konzept hängt:
|
||||||
|
|
||||||
|
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||||
|
|
||||||
|
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||||
|
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||||
|
|
||||||
|
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install koffi
|
||||||
|
node laufwerk.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||||
|
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||||
|
|
||||||
|
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||||
|
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||||
|
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||||
|
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||||
|
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||||
|
| Rev: 1.03 | Serial: 0025114C0149
|
||||||
|
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||||
|
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||||
|
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||||
|
```
|
||||||
|
|
||||||
|
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||||
|
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||||
|
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||||
|
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||||
|
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||||
|
|
||||||
|
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||||
|
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||||
|
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||||
|
ein antwortendes Laufwerk für tot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `leine.js` — die Prozess-Leine (Job Object)
|
||||||
|
|
||||||
|
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||||
|
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||||
|
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||||
|
|
||||||
|
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||||
|
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||||
|
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||||
|
Drüber-Installieren passiert.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||||
|
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||||
|
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||||
|
4. Kindprozess gestartet, PID 16040
|
||||||
|
|
||||||
|
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||||
|
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||||
|
danach: Eltern lebt: False Kind lebt: False
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachstellen (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||||
|
Start-Sleep 3
|
||||||
|
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||||
|
taskkill /F /PID $p.Id
|
||||||
|
Start-Sleep 2
|
||||||
|
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||||
|
|
||||||
|
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||||
|
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||||
|
überhaupt vorhanden?
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||||
|
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||||
|
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||||
|
Kern beendet mit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||||
|
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||||
|
Messung zuvor ebenfalls grün.)
|
||||||
|
|
||||||
|
Nachstellen:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install electron@44 --no-save
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||||
|
|
||||||
|
```
|
||||||
|
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||||
|
```
|
||||||
|
|
||||||
|
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||||
|
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||||
|
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||||
|
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||||
|
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||||
|
Tages jemand an der falschen Stelle.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||||
|
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
|
||||||
|
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||||
|
const ctl = (typ, fn, methode, zugriff) =>
|
||||||
|
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||||
|
|
||||||
|
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||||
|
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||||
|
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||||
|
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||||
|
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||||
|
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||||
|
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||||
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
|
||||||
|
|
||||||
|
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||||
|
const DRIVE_CDROM = 5;
|
||||||
|
|
||||||
|
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||||
|
const maske = GetLogicalDrives();
|
||||||
|
const optische = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
if (!(maske & (1 << i))) continue;
|
||||||
|
const buchstabe = String.fromCharCode(65 + i);
|
||||||
|
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||||
|
}
|
||||||
|
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||||
|
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const lw = optische[0];
|
||||||
|
|
||||||
|
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||||
|
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||||
|
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||||
|
function oeffnen(zugriff) {
|
||||||
|
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||||
|
const adr = koffi.address(h);
|
||||||
|
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||||
|
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||||
|
return { h, fehler: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ohneMedium = oeffnen(0);
|
||||||
|
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||||
|
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||||
|
const mitLesen = oeffnen(GENERIC_READ);
|
||||||
|
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||||
|
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||||
|
|
||||||
|
const h = ohneMedium.h;
|
||||||
|
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const rueck = Buffer.alloc(4);
|
||||||
|
|
||||||
|
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||||
|
const anfrage = Buffer.alloc(12);
|
||||||
|
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||||
|
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||||
|
const antwort = Buffer.alloc(1024);
|
||||||
|
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||||
|
const text = (off) => {
|
||||||
|
const start = antwort.readUInt32LE(off);
|
||||||
|
if (!start || start >= antwort.length) return '';
|
||||||
|
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||||
|
return antwort.toString('latin1', start, ende).trim();
|
||||||
|
};
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||||
|
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||||
|
} else {
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||||
|
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||||
|
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||||
|
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||||
|
|
||||||
|
// ── 5. Audio- oder Datenspur?
|
||||||
|
const typBuf = Buffer.alloc(1);
|
||||||
|
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||||
|
const t = typBuf[0];
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||||
|
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||||
|
} else {
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||||
|
const laenge = Buffer.alloc(8);
|
||||||
|
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||||
|
const bytes = laenge.readBigUInt64LE(0);
|
||||||
|
const gb = Number(bytes) / 1e9;
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||||
|
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||||
|
} else {
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(h);
|
||||||
|
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||||
|
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||||
|
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||||
|
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||||
|
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||||
|
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||||
|
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
|
||||||
|
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||||
|
const JobObjectExtendedLimitInformation = 9;
|
||||||
|
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||||
|
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||||
|
|
||||||
|
const job = CreateJobObjectW(null, null);
|
||||||
|
if (koffi.address(job) === 0n) {
|
||||||
|
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||||
|
|
||||||
|
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||||
|
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||||
|
const info = Buffer.alloc(144);
|
||||||
|
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||||
|
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||||
|
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||||
|
|
||||||
|
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||||
|
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||||
|
|
||||||
|
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||||
|
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||||
|
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||||
|
console.log('ELTERN-PID=' + process.pid);
|
||||||
|
console.log('KIND-PID=' + kind.pid);
|
||||||
|
|
||||||
|
// Offen halten, bis uns jemand hart abschiesst.
|
||||||
|
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||||
|
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||||
|
try {
|
||||||
|
const s = require('node:sqlite');
|
||||||
|
const db = new s.DatabaseSync(':memory:');
|
||||||
|
db.exec('CREATE TABLE t (a TEXT)');
|
||||||
|
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||||
|
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||||
|
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||||
|
} catch (e) {
|
||||||
|
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||||
|
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||||
|
//
|
||||||
|
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||||
|
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||||
|
const { app, utilityProcess } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||||
|
kind.on('message', (antwort) => {
|
||||||
|
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||||
|
app.exit(antwort.ok ? 0 : 1);
|
||||||
|
});
|
||||||
|
kind.on('exit', (code) => {
|
||||||
|
console.log('Kern beendet mit ' + code);
|
||||||
|
if (code !== 0) app.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -95,6 +95,18 @@ fi
|
|||||||
sed -i '/^RIPPY_VERSION=/d' .env
|
sed -i '/^RIPPY_VERSION=/d' .env
|
||||||
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
|
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
|
||||||
|
|
||||||
|
# Geraete dieses Hosts ermitteln, BEVOR gestartet wird.
|
||||||
|
# Ohne diesen Schritt haengt der Start an einem `devices:`-Eintrag, den es
|
||||||
|
# vielleicht nicht mehr gibt — am 28.08.2026 legte genau das die ganze
|
||||||
|
# Installation stillt, weil das USB-Laufwerk abgezogen war (Herleitung im Kopf
|
||||||
|
# von docker-compose.yml). Das Skript schreibt die Override immer neu, auch
|
||||||
|
# wenn kein Laufwerk da ist.
|
||||||
|
if [ -x ./deploy/geraete-override.sh ]; then
|
||||||
|
./deploy/geraete-override.sh .
|
||||||
|
else
|
||||||
|
echo "WARNUNG: deploy/geraete-override.sh fehlt — starte ohne Geraete-Erkennung."
|
||||||
|
fi
|
||||||
|
|
||||||
docker compose -p rippy up -d --build $DIENST
|
docker compose -p rippy up -d --build $DIENST
|
||||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Erzeugt docker-compose.override.yml mit den optischen Laufwerken DIESES Hosts.
|
||||||
|
#
|
||||||
|
# ## Warum es das gibt (Vorfall 28.08.2026)
|
||||||
|
#
|
||||||
|
# In docker-compose.yml stand:
|
||||||
|
#
|
||||||
|
# devices:
|
||||||
|
# - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||||
|
#
|
||||||
|
# Das ist eine STARTBEDINGUNG. Fehlt das Laufwerk, startet der Container nicht:
|
||||||
|
#
|
||||||
|
# Error response from daemon: error gathering device information while
|
||||||
|
# adding custom device "/dev/sr0": no such file or directory
|
||||||
|
#
|
||||||
|
# Genau das ist passiert, als das USB-Laufwerk von der VM abgezogen wurde: Ein
|
||||||
|
# ganz normaler Deploy legte api, worker UND ui still — obwohl keiner der drei
|
||||||
|
# ein Laufwerk zum Starten braucht. Rippy war unten, und die Meldung stand
|
||||||
|
# mitten im Build-Rauschen.
|
||||||
|
#
|
||||||
|
# Der Widerspruch war schon vorher da: `install.sh` sagt bei fehlendem Laufwerk
|
||||||
|
# ausdrücklich „die Installation läuft trotzdem durch; diese Maschine dient dann
|
||||||
|
# als reine KOMPRIMIER-Maschine" — die Compose-Datei sah das anders. Eine
|
||||||
|
# Maschine ohne Laufwerk ist ein vorgesehener Betriebsfall (der Windows-Worker
|
||||||
|
# und der GPU-Encoder sind genau das).
|
||||||
|
#
|
||||||
|
# Seither steht in docker-compose.yml KEIN devices-Block mehr. Was dieser Host
|
||||||
|
# wirklich hat, landet in docker-compose.override.yml — die zieht Compose von
|
||||||
|
# allein dazu (`docker compose up` braucht keinen extra Schalter), und sie ist
|
||||||
|
# gitignored, weil die Geräteknoten je Rechner anders heißen.
|
||||||
|
#
|
||||||
|
# Aufruf: ./deploy/geraete-override.sh [zielverzeichnis]
|
||||||
|
# (ohne Argument: das aktuelle Verzeichnis)
|
||||||
|
#
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ZIEL="${1:-.}"
|
||||||
|
DATEI="$ZIEL/docker-compose.override.yml"
|
||||||
|
|
||||||
|
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht deshalb
|
||||||
|
# ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer dazugehört,
|
||||||
|
# ist je Host anders — sie wird über die SCSI-Adresse ermittelt statt geraten.
|
||||||
|
# Beide Knoten zeigen in /sys auf dasselbe Geräteverzeichnis (z. B. 3:0:0:0).
|
||||||
|
# (Dieselbe Herleitung wie in install.sh, Prüfung 2/5.)
|
||||||
|
SR=""
|
||||||
|
SG=""
|
||||||
|
LAUFWERK=""
|
||||||
|
for srpfad in /sys/block/sr*; do
|
||||||
|
[ -e "$srpfad" ] || continue
|
||||||
|
srname=$(basename "$srpfad")
|
||||||
|
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
|
||||||
|
[ -n "$adresse" ] || continue
|
||||||
|
for sgpfad in /sys/class/scsi_generic/sg*; do
|
||||||
|
[ -e "$sgpfad" ] || continue
|
||||||
|
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
|
||||||
|
SR="/dev/$srname"
|
||||||
|
SG="/dev/$(basename "$sgpfad")"
|
||||||
|
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
|
||||||
|
break 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
kopf() {
|
||||||
|
cat <<KOPF
|
||||||
|
# ERZEUGT von deploy/geraete-override.sh — nicht von Hand ändern.
|
||||||
|
# Diese Datei ist gitignored: Die Geräteknoten heißen auf jedem Rechner anders.
|
||||||
|
# Neu erzeugen nach jedem An- oder Abstecken eines Laufwerks:
|
||||||
|
# ./deploy/geraete-override.sh && docker compose up -d
|
||||||
|
KOPF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$SR" ]; then
|
||||||
|
# KEIN Laufwerk. Wichtig: trotzdem eine gültige Datei schreiben und die alte
|
||||||
|
# damit überschreiben. Bliebe eine alte Override mit /dev/sr0 liegen, wäre der
|
||||||
|
# Fehler beim nächsten Start genau derselbe — nur schwerer zu finden, weil die
|
||||||
|
# Datei gitignored ist und in keinem Diff auftaucht.
|
||||||
|
{
|
||||||
|
kopf
|
||||||
|
echo "#"
|
||||||
|
echo "# Auf diesem Host wurde KEIN optisches Laufwerk gefunden."
|
||||||
|
echo "# Rippy läuft damit als reine Komprimier-Maschine — das ist ein"
|
||||||
|
echo "# vorgesehener Betriebsfall, kein Fehler. Zum Rippen das Laufwerk"
|
||||||
|
echo "# anstecken (in einer VM: USB-Passthrough) und dieses Skript erneut"
|
||||||
|
echo "# aufrufen."
|
||||||
|
echo "services: {}"
|
||||||
|
} > "$DATEI"
|
||||||
|
echo "Kein optisches Laufwerk gefunden — $DATEI ohne Geräte geschrieben."
|
||||||
|
echo "Rippy startet und kann komprimieren; Rippen geht erst mit Laufwerk."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
kopf
|
||||||
|
echo "#"
|
||||||
|
echo "# Gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
|
||||||
|
echo "# $SR + $SG (über die SCSI-Adresse abgeglichen, nicht geraten)"
|
||||||
|
cat <<YAML
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
devices:
|
||||||
|
- $SR:/dev/sr0
|
||||||
|
worker:
|
||||||
|
devices:
|
||||||
|
- $SR:/dev/sr0
|
||||||
|
- $SG:/dev/sg1
|
||||||
|
YAML
|
||||||
|
} > "$DATEI"
|
||||||
|
|
||||||
|
echo "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
|
||||||
|
echo " $SR + $SG -> $DATEI"
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 31 KiB |
@@ -3,7 +3,24 @@
|
|||||||
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
|
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
|
||||||
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
|
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
|
||||||
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
|
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
|
||||||
# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve).
|
# mit EPERM.
|
||||||
|
#
|
||||||
|
# ⚠️ ABER NICHT HIER (Vorfall 28.08.2026): Ein `devices:`-Eintrag ist eine
|
||||||
|
# STARTBEDINGUNG. Als das USB-Laufwerk von der VM abgezogen wurde, legte ein
|
||||||
|
# ganz normaler Deploy api, worker UND ui still — obwohl keiner der drei zum
|
||||||
|
# Starten ein Laufwerk braucht:
|
||||||
|
#
|
||||||
|
# Error response from daemon: error gathering device information while
|
||||||
|
# adding custom device "/dev/sr0": no such file or directory
|
||||||
|
#
|
||||||
|
# Der Widerspruch war älter: install.sh sagt bei fehlendem Laufwerk
|
||||||
|
# ausdrücklich „läuft trotzdem durch, dann ist das eine reine
|
||||||
|
# KOMPRIMIER-Maschine" — diese Datei sah das anders.
|
||||||
|
#
|
||||||
|
# Deshalb stehen die Geräte jetzt in docker-compose.override.yml, erzeugt von
|
||||||
|
# `deploy/geraete-override.sh`. Compose zieht die Override von allein dazu;
|
||||||
|
# `docker compose up -d` bleibt derselbe Befehl. Nach jedem An- oder Abstecken
|
||||||
|
# eines Laufwerks das Skript erneut aufrufen.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
@@ -54,8 +71,7 @@ services:
|
|||||||
- temp:/app/temp
|
- temp:/app/temp
|
||||||
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
|
||||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
|
||||||
devices:
|
# devices: siehe Kopf — kommen aus docker-compose.override.yml
|
||||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -118,15 +134,11 @@ services:
|
|||||||
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
|
||||||
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
|
||||||
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
|
||||||
devices:
|
# devices: siehe Kopf dieser Datei — die optischen Knoten dieses Hosts
|
||||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
# stehen in docker-compose.override.yml (deploy/geraete-override.sh).
|
||||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
# MakeMKV braucht BEIDE: /dev/srN und den passenden /dev/sgM; welche
|
||||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
# sg-Nummer dazugehoert, ist je Host anders und wird dort ueber die
|
||||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
# SCSI-Adresse abgeglichen statt geraten.
|
||||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
|
||||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
|
||||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
|
||||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
|
||||||
networks:
|
networks:
|
||||||
- rippy-net
|
- rippy-net
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
global _erreichbar
|
||||||
|
try:
|
||||||
value = redis_client.get(key)
|
value = redis_client.get(key)
|
||||||
|
except (RedisError, OSError) as e:
|
||||||
|
_melde_einmal(e)
|
||||||
|
return None
|
||||||
|
_erreichbar = True
|
||||||
if value:
|
if value:
|
||||||
|
try:
|
||||||
return json.loads(value)
|
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."""
|
||||||
|
global _erreichbar
|
||||||
|
try:
|
||||||
serialized = json.dumps(value)
|
serialized = json.dumps(value)
|
||||||
if expire:
|
if expire:
|
||||||
return redis_client.setex(key, expire, serialized)
|
return bool(redis_client.setex(key, expire, serialized))
|
||||||
return redis_client.set(key, 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 rippy.drives.detection import (
|
|
||||||
CDS_DISC_OK,
|
|
||||||
classify,
|
|
||||||
disc_size_bytes,
|
|
||||||
disc_status,
|
|
||||||
drive_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
# include/uapi/linux/cdrom.h
|
|
||||||
CDROMEJECT = 0x5309
|
|
||||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
|
||||||
CDS_NO_DISC = 1
|
|
||||||
CDS_TRAY_OPEN = 2
|
|
||||||
|
|
||||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
|
||||||
|
|
||||||
|
|
||||||
def eject(device_path: str) -> None:
|
|
||||||
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
|
|
||||||
|
|
||||||
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
|
|
||||||
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
|
|
||||||
und tut nichts. MakeMKV verriegelt die Tür während des Rips
|
|
||||||
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
|
|
||||||
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
|
|
||||||
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
|
|
||||||
Deshalb macht das Werkzeug `eject` immer beides.
|
|
||||||
|
|
||||||
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
|
|
||||||
zwischen den Containern; dort steht die ausführliche Herleitung.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
ioctl(fd, CDROM_LOCKDOOR, 0)
|
|
||||||
except OSError:
|
|
||||||
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
|
|
||||||
ioctl(fd, CDROMEJECT, 0)
|
|
||||||
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
|
|
||||||
# Slot-Laufwerk hat keine Schublade.
|
|
||||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
|
||||||
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
|
||||||
return
|
|
||||||
time.sleep(1)
|
|
||||||
raise OSError(
|
|
||||||
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
|
||||||
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def list_optical_devices() -> list:
|
|
||||||
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
|
||||||
return sorted(glob.glob("/dev/sr[0-9]*"))
|
|
||||||
|
|
||||||
|
|
||||||
def read_sys_attr(device_name: str, attr: str) -> str:
|
|
||||||
pfad = f"/sys/class/block/{device_name}/device/{attr}"
|
|
||||||
try:
|
|
||||||
with open(pfad, encoding="ascii", errors="replace") as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def device_info(device_path: str) -> dict:
|
|
||||||
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl."""
|
|
||||||
name = os.path.basename(device_path)
|
|
||||||
vendor = read_sys_attr(name, "vendor")
|
|
||||||
model = read_sys_attr(name, "model")
|
|
||||||
|
|
||||||
try:
|
|
||||||
status_code = drive_status(device_path)
|
|
||||||
except OSError:
|
|
||||||
status_code = -1
|
|
||||||
|
|
||||||
disc_type = "unknown"
|
|
||||||
status = "empty"
|
|
||||||
if status_code == CDS_DISC_OK:
|
|
||||||
status = "ready"
|
|
||||||
try:
|
|
||||||
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
|
||||||
except OSError:
|
|
||||||
disc_type = "unknown"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": name,
|
|
||||||
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
|
|
||||||
"type": disc_type,
|
|
||||||
"path": device_path,
|
|
||||||
"status": status,
|
|
||||||
"model": model,
|
|
||||||
"serial": read_sys_attr(name, "wwid"),
|
|
||||||
}
|
|
||||||
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
|
|||||||
return f"eta:{job_id}"
|
return f"eta:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||||||
|
#:
|
||||||
|
#: ## Der Befund des Commanders (29.08.2026)
|
||||||
|
#:
|
||||||
|
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||||
|
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#:
|
||||||
|
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||||||
|
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||||||
|
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||||||
|
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||||||
|
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||||||
|
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||||||
|
#:
|
||||||
|
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||||||
|
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||||||
|
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||||||
|
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||||||
|
_REIHEN: dict = {}
|
||||||
|
|
||||||
|
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||||||
|
REIHE_HALTBAR_SEKUNDEN = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _aufraeumen(jetzt: float) -> None:
|
||||||
|
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||||||
|
for k, (stand, _) in list(_REIHEN.items()):
|
||||||
|
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||||||
|
_REIHEN.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
jetzt: float, cache_get, cache_set) -> dict:
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||||
keine ETA, aber nichts scheitert.
|
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||||
"""
|
"""
|
||||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
return {"sekunden": -1, "text": ""}
|
return {"sekunden": -1, "text": ""}
|
||||||
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
|||||||
reihe = cache_get(k)
|
reihe = cache_get(k)
|
||||||
except Exception:
|
except Exception:
|
||||||
reihe = None
|
reihe = None
|
||||||
|
if not reihe:
|
||||||
|
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||||||
|
eintrag = _REIHEN.get(k)
|
||||||
|
reihe = eintrag[1] if eintrag else None
|
||||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
try:
|
try:
|
||||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
cache_set(k, reihe, expire=86400)
|
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_REIHEN[k] = (jetzt, reihe)
|
||||||
|
_aufraeumen(jetzt)
|
||||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def fetch_current_key(timeout: int = 15) -> str | None:
|
|||||||
def _apply_key(key: str) -> bool:
|
def _apply_key(key: str) -> bool:
|
||||||
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
|
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
|
||||||
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
||||||
import db
|
from rippy import store as db
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
||||||
return False
|
return False
|
||||||
@@ -61,7 +61,7 @@ def _apply_key(key: str) -> bool:
|
|||||||
|
|
||||||
async def refresh_once() -> bool:
|
async def refresh_once() -> bool:
|
||||||
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
||||||
import db
|
from rippy import store as db
|
||||||
key = await asyncio.to_thread(fetch_current_key)
|
key = await asyncio.to_thread(fetch_current_key)
|
||||||
if not key:
|
if not key:
|
||||||
db.add_log("warning", "makemkv-key",
|
db.add_log("warning", "makemkv-key",
|
||||||
@@ -77,7 +77,7 @@ async def refresh_once() -> bool:
|
|||||||
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
||||||
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
||||||
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
|
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
|
||||||
import db
|
from rippy import store as db
|
||||||
await asyncio.sleep(start_verzoegerung_s)
|
await asyncio.sleep(start_verzoegerung_s)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import db
|
from rippy import store as db
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
|
||||||
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
["timeout", "3", "ls", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=5
|
||||||
)
|
)
|
||||||
return ergebnis.returncode == 0
|
return ergebnis.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
["timeout", "4", "ls", "-d", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=6
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unklar"
|
return "unklar"
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
|||||||
NICHTS = ""
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
def _meta(meta_json) -> dict:
|
def meta_von(meta_json) -> dict:
|
||||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||||
|
|
||||||
|
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||||
|
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||||
|
"""
|
||||||
if isinstance(meta_json, dict):
|
if isinstance(meta_json, dict):
|
||||||
return meta_json
|
return meta_json
|
||||||
if not meta_json:
|
if not meta_json:
|
||||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
|||||||
"""
|
"""
|
||||||
if (job.get("status") or "") != "failed":
|
if (job.get("status") or "") != "failed":
|
||||||
return NICHTS
|
return NICHTS
|
||||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||||
if marke is True:
|
if marke is True:
|
||||||
return NEU_KOMPRIMIEREN
|
return NEU_KOMPRIMIEREN
|
||||||
if marke is False:
|
if marke is False:
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from rippy.drives 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 read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
def disc_wurzel(device_path: str):
|
||||||
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
|
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
|
||||||
|
|
||||||
Das Volume-Label ist oft kryptisch (BD_EVG_D2) — der echte Titel
|
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
|
||||||
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
|
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
|
||||||
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
|
|
||||||
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
## 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.
|
||||||
"""
|
"""
|
||||||
import re as _re
|
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"
|
mountpoint = "/mnt/rippy-disc"
|
||||||
os.makedirs(mountpoint, exist_ok=True)
|
os.makedirs(mountpoint, exist_ok=True)
|
||||||
gemountet = False
|
|
||||||
try:
|
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
return None
|
return None, lambda: None
|
||||||
gemountet = True
|
|
||||||
|
|
||||||
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
|
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):
|
if not os.path.isdir(meta_dir):
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
kandidaten = sorted(os.listdir(meta_dir))
|
kandidaten = sorted(os.listdir(meta_dir))
|
||||||
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
|
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(
|
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
|
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
||||||
)
|
)
|
||||||
if not bdmt:
|
if not bdmt:
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||||
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
|
except OSError:
|
||||||
|
return None
|
||||||
|
# <di:name>Titel</di:name> - bewusst per Regex statt XML-Parser
|
||||||
# (Namespaces variieren je Authoring-Werkzeug)
|
# (Namespaces variieren je Authoring-Werkzeug)
|
||||||
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
||||||
if treffer:
|
return treffer.group(1).strip() if treffer else None
|
||||||
return treffer.group(1).strip()
|
|
||||||
return None
|
|
||||||
|
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
||||||
|
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
|
||||||
|
|
||||||
|
Das Volume-Label ist oft kryptisch (`BD_EVG_D2`) - der echte Titel
|
||||||
|
(„Evangelion 2.22") steht in den Disc-Metadaten. Unter Linux wird dafuer
|
||||||
|
read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
|
||||||
|
Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
|
||||||
|
"""
|
||||||
|
wurzel, aufraeumen = None, lambda: None
|
||||||
|
try:
|
||||||
|
wurzel, aufraeumen = disc_wurzel(device_path)
|
||||||
|
return titel_aus_bdmt(wurzel) if wurzel else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if gemountet:
|
try:
|
||||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
aufraeumen()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def titel_kandidaten(titel: str) -> List[str]:
|
def titel_kandidaten(titel: str) -> List[str]:
|
||||||
@@ -273,7 +331,8 @@ class PreScan:
|
|||||||
["cdparanoia", "-Q", device_path],
|
["cdparanoia", "-Q", device_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if 'track' in line.lower():
|
if 'track' in line.lower():
|
||||||
@@ -297,22 +356,37 @@ class PreScan:
|
|||||||
if label:
|
if label:
|
||||||
toc["title"] = normalize_disc_label(label)
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||||
if shutil.which("makemkvcon"):
|
#
|
||||||
result = subprocess.run(
|
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||||
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
#
|
||||||
capture_output=True,
|
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||||
text=True,
|
# lange. Das ging mal viel schneller."
|
||||||
timeout=120
|
#
|
||||||
)
|
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||||
for line in result.stdout.split('\n'):
|
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||||
if line.startswith('TINFO:'):
|
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||||
parts = line.split(',')
|
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||||
if len(parts) >= 5:
|
#
|
||||||
toc["tracks"].append({
|
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
# `shutil.which`, und das findet unter Windows nichts
|
||||||
})
|
# (Programme liegen nicht im PATH). `be3fac5` hat das am
|
||||||
|
# 28.08.2026 richtig repariert — und damit erst die Kosten
|
||||||
|
# sichtbar gemacht, die hier immer schon standen.
|
||||||
|
#
|
||||||
|
# Der Aufwand war umsonst: Das Ergebnis landete allein in
|
||||||
|
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
|
||||||
|
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
|
||||||
|
# und `GET /devices/{id}/tracks` — also dann, wenn sie
|
||||||
|
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
|
||||||
|
# Der Weg fuer eine Disc, die man gar nicht rippen will,
|
||||||
|
# sind so zwei Minuten Warten fuer nichts.
|
||||||
|
#
|
||||||
|
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
|
||||||
|
# billig. Wer den Aufruf je wieder braucht, braucht dazu
|
||||||
|
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
|
||||||
|
# `tools.katalog` — die Importe sind mit ihm gegangen.
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
@@ -389,11 +463,13 @@ class PreScan:
|
|||||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||||
kandidaten = titel_kandidaten(title) or [title]
|
kandidaten = titel_kandidaten(title) or [title]
|
||||||
movies = []
|
movies = []
|
||||||
|
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||||
|
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
treffer = self.tmdb.search_movie(kandidat)
|
treffer = self.tmdb.search_movie(kandidat)
|
||||||
if treffer and not movies:
|
if treffer and not movies:
|
||||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||||
|
movies_kandidat = kandidat
|
||||||
for movie in treffer:
|
for movie in treffer:
|
||||||
if movie.get("title", "").lower() == kandidat.lower():
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
@@ -440,6 +516,55 @@ class PreScan:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
|
||||||
|
#
|
||||||
|
# ## Der Befund des Commanders (28.08.2026)
|
||||||
|
#
|
||||||
|
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
|
||||||
|
# > wir ein cover."
|
||||||
|
#
|
||||||
|
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
|
||||||
|
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
|
||||||
|
# == kandidat.lower()`. An seiner Disc gemessen:
|
||||||
|
#
|
||||||
|
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||||
|
# 'Evangelion' 20 Treffer irgendein Evangelion
|
||||||
|
#
|
||||||
|
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
|
||||||
|
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
|
||||||
|
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
|
||||||
|
#
|
||||||
|
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
|
||||||
|
#
|
||||||
|
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
|
||||||
|
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
|
||||||
|
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
|
||||||
|
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
|
||||||
|
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
|
||||||
|
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
|
||||||
|
#
|
||||||
|
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
|
||||||
|
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
|
||||||
|
# ehrlich unter einem wörtlichen Treffer (0,95).
|
||||||
|
if not matched and movies and movies_kandidat == kandidaten[0] \
|
||||||
|
and len(movies) <= WENIGE_TREFFER:
|
||||||
|
details = self.tmdb.get_movie_details(movies[0]["id"])
|
||||||
|
if details:
|
||||||
|
confidence = 0.8
|
||||||
|
metadata = {
|
||||||
|
"type": "movie",
|
||||||
|
"id": movies[0]["id"],
|
||||||
|
"title": details.get("title", title),
|
||||||
|
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
|
||||||
|
"overview": details.get("overview", ""),
|
||||||
|
"poster_path": details.get("poster_path", ""),
|
||||||
|
"backdrop_path": details.get("backdrop_path", ""),
|
||||||
|
"runtime": details.get("runtime", 0),
|
||||||
|
"genres": [g["name"] for g in details.get("genres", [])],
|
||||||
|
"source": "tmdb",
|
||||||
|
}
|
||||||
|
matched = True
|
||||||
|
|
||||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -38,18 +38,71 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
|||||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
from rippy import pfade as _pfade
|
||||||
|
|
||||||
|
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||||||
|
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||||||
RAW_STANDARD = "/app/temp/raw"
|
RAW_STANDARD = "/app/temp/raw"
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_einstellungen() -> dict:
|
||||||
|
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||||
|
gerade nicht antwortet.
|
||||||
|
|
||||||
|
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||||
|
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||||
|
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||||
|
Vorgabe, wie bisher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rippy import store as db
|
||||||
|
|
||||||
|
return db.get_settings(bei_fehler_leer=True) or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def wurzeln(werte=None) -> tuple:
|
||||||
|
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||||
|
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||||
|
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||||
|
`/app/media`.
|
||||||
|
|
||||||
|
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||||
|
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||||
|
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||||
|
4K-UHD bis zu 100 GB.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
|
||||||
|
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
|
||||||
|
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
|
||||||
|
# was eingestellt ist (Befund 30.08.2026).
|
||||||
|
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
|
||||||
|
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||||
|
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||||
|
|
||||||
|
|
||||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||||
|
|
||||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||||
@@ -65,13 +118,37 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
"""
|
"""
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return []
|
return []
|
||||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||||
wahl = (work_dir or "").strip().rstrip("/")
|
from rippy import pfade
|
||||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
|
||||||
orte.append(posixpath.join(wahl, job_id))
|
orte = [pfade.verbinden(roh, job_id)]
|
||||||
|
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
|
||||||
|
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
|
||||||
|
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
|
||||||
|
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
|
||||||
|
#
|
||||||
|
# "F:\\".rstrip("/\\") -> "F:"
|
||||||
|
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
|
||||||
|
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
|
||||||
|
#
|
||||||
|
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
|
||||||
|
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
|
||||||
|
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
|
||||||
|
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||||
|
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
|
||||||
|
wahl = (work_dir or "").strip()
|
||||||
|
vergleich = wahl.rstrip("/\\")
|
||||||
|
grenze = (medien or "").rstrip("/\\")
|
||||||
|
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||||
|
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||||
|
if vergleich and (frei or vergleich == grenze
|
||||||
|
or vergleich.startswith(grenze + "/")):
|
||||||
|
orte.append(pfade.verbinden(wahl, job_id))
|
||||||
for name in media_unterordner or []:
|
for name in media_unterordner or []:
|
||||||
if name:
|
if name:
|
||||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||||
gesehen, eindeutig = set(), []
|
gesehen, eindeutig = set(), []
|
||||||
for ort in orte:
|
for ort in orte:
|
||||||
if ort not in gesehen:
|
if ort not in gesehen:
|
||||||
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def nativ_nachsehen() -> bool:
|
||||||
|
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||||
|
|
||||||
|
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||||
|
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||||
|
`pruefen`.
|
||||||
|
"""
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def pruefen(pfad: str, laufen=None) -> str:
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
|||||||
"""
|
"""
|
||||||
if not pfad:
|
if not pfad:
|
||||||
return "weg"
|
return "weg"
|
||||||
|
if laufen is None and nativ_nachsehen():
|
||||||
|
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
#
|
||||||
|
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||||
|
# 14:54:40 WindowsTerminal.exe
|
||||||
|
#
|
||||||
|
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||||
|
# gehen ganz kurz auf und dann wieder zu."
|
||||||
|
#
|
||||||
|
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||||
|
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||||
|
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||||
|
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||||
|
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||||
|
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||||
|
# unter Windows grundsaetzlich unsichtbar.
|
||||||
|
#
|
||||||
|
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||||
|
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||||
|
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||||
|
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||||
|
try:
|
||||||
|
return "da" if os.path.isdir(pfad) else "weg"
|
||||||
|
except OSError:
|
||||||
|
return "unklar"
|
||||||
starten = laufen or subprocess.run
|
starten = laufen or subprocess.run
|
||||||
try:
|
try:
|
||||||
ergebnis = starten(
|
ergebnis = starten(
|
||||||
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
|||||||
return pruefen(pfad, laufen) == "da"
|
return pruefen(pfad, laufen) == "da"
|
||||||
|
|
||||||
|
|
||||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Die Orte, an denen wirklich etwas liegt.
|
"""Die Orte, an denen wirklich etwas liegt.
|
||||||
|
|
||||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||||
@@ -156,12 +271,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||||
"""
|
"""
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden = []
|
gefunden = []
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
try:
|
try:
|
||||||
if isdir(ort):
|
if isdir(ort):
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
|
||||||
|
orte_wurzeln=None) -> dict:
|
||||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||||
|
|
||||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||||
@@ -181,12 +298,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
|||||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||||
"""
|
"""
|
||||||
pruefe = pruefer or pruefen
|
pruefe = pruefer or pruefen
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden, unklar = [], False
|
gefunden, unklar = [], False
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
antwort = pruefe(ort)
|
antwort = pruefe(ort)
|
||||||
if antwort == "da":
|
if antwort == "da":
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for name in namen:
|
for name in namen:
|
||||||
voll = posixpath.join(pfad, name)
|
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||||||
|
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||||||
|
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||||||
|
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||||||
|
voll = _pfade.verbinden(pfad, name)
|
||||||
try:
|
try:
|
||||||
if isfile(voll):
|
if isfile(voll):
|
||||||
bytes_gesamt += getsize(voll)
|
bytes_gesamt += getsize(voll)
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
||||||
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
||||||
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
|
||||||
wo auch die Ampel läuft.
|
|
||||||
|
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
|
||||||
|
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
|
||||||
|
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
|
||||||
|
Plattformen — nachgemessen: 57 Routen, sauberer Import.
|
||||||
|
|
||||||
|
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
|
||||||
|
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
|
||||||
|
Plattform greift, ist eine halbe Zusage — genau daran ist die Ampel am
|
||||||
|
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
if sys.platform == "win32": # pragma: no cover
|
|
||||||
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_importierbar_und_routen_verdrahtet():
|
def test_main_importierbar_und_routen_verdrahtet():
|
||||||
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
|
|||||||
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||||
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||||
"/system/keystore",
|
"/system/keystore",
|
||||||
|
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
|
||||||
|
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
|
||||||
|
"/betrieb",
|
||||||
):
|
):
|
||||||
assert pfad in routen, f"Route {pfad} fehlt"
|
assert pfad in routen, f"Route {pfad} fehlt"
|
||||||
|
|
||||||
@@ -246,3 +253,682 @@ def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
|
|||||||
zustand["da"] = True
|
zustand["da"] = True
|
||||||
main._mounts_nachsehen()
|
main._mounts_nachsehen()
|
||||||
assert any("antwortet wieder" in m for m in gelogged)
|
assert any("antwortet wieder" in m for m in gelogged)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Live-Ereignisse (SSE), Etappe V2-3 --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_route_ist_verdrahtet():
|
||||||
|
"""Ohne diese Route fällt das UI stumm auf seinen letzten Stand zurück —
|
||||||
|
und weil ein Abriss KEINE Aussage ist, sähe der Nutzer einfach nichts
|
||||||
|
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
assert "/events" in {route.path for route in app.routes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():
|
||||||
|
"""`id:` ist nicht Kosmetik — der Browser schickt genau diesen Wert beim
|
||||||
|
Wiederverbinden als Last-Event-ID zurück. Fehlt er, gibt es keine
|
||||||
|
lückenlose Wiederaufnahme, und jeder WLAN-Wechsel reisst ein Loch."""
|
||||||
|
from main import _sse_rahmen
|
||||||
|
|
||||||
|
rahmen = _sse_rahmen({"seq": 42, "typ": "job.progress", "daten": {"prozent": 7}})
|
||||||
|
zeilen = rahmen.split("\n")
|
||||||
|
assert zeilen[0] == "id: 42"
|
||||||
|
assert zeilen[1] == "event: job.progress"
|
||||||
|
assert zeilen[2].startswith("data: {")
|
||||||
|
# Zwei Leerzeilen am Ende: eine schliesst das Ereignis, die zweite ist der
|
||||||
|
# Trenner. Ohne den doppelten Umbruch haelt der Browser das Ereignis fuer
|
||||||
|
# unvollstaendig und liefert es NIE aus.
|
||||||
|
assert rahmen.endswith("\n\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_rahmen_uebersteht_umlaute():
|
||||||
|
"""Job-Titel und Log-Zeilen sind deutsch. Mit ensure_ascii=True kaeme
|
||||||
|
"Gr\u00f6\u00dfe" beim Nutzer an."""
|
||||||
|
from main import _sse_rahmen
|
||||||
|
|
||||||
|
rahmen = _sse_rahmen({"seq": 1, "typ": "log.line", "daten": {"text": "Größe"}})
|
||||||
|
assert "Größe" in rahmen
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
|
||||||
|
"""„konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||||
|
|
||||||
|
Genau diese Vermischung hat in v1 die Job-Liste im Sekundentakt geleert
|
||||||
|
(fuenfmal `catch(() => [])` im UI). Ein Snapshot mit devices=[] wuerde dem
|
||||||
|
UI sagen „du hast kein Laufwerk"; None sagt „ich weiss es gerade nicht",
|
||||||
|
und das UI behaelt seinen Stand.
|
||||||
|
|
||||||
|
OHNE DATENBANK: Die Ampel hat keine Postgres — der erste Anlauf dieses
|
||||||
|
Tests lief deshalb rot (Lauf 170). Der Store wird hier ersetzt, denn
|
||||||
|
geprueft wird die Snapshot-LOGIK, nicht die Datenbank.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||||
|
monkeypatch.setattr(main.db, "list_workers", lambda: [])
|
||||||
|
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||||
|
|
||||||
|
def kaputt():
|
||||||
|
raise OSError("Laufwerk haengt")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", kaputt)
|
||||||
|
|
||||||
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
|
assert zustand["devices"] is None, "Ein unlesbares Laufwerk darf nicht als [] durchgehen"
|
||||||
|
assert zustand["jobs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
||||||
|
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
|
||||||
|
er den Rest raten und faellt auf Polling zurueck.
|
||||||
|
|
||||||
|
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
|
||||||
|
|
||||||
|
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
|
||||||
|
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
|
||||||
|
Viertelminute auf „Platz fuer Rippy: unbekannt" — und das sieht aus wie
|
||||||
|
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
|
||||||
|
Windows-Fenster gesehen.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||||
|
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
|
||||||
|
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
|
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||||
|
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||||
|
#
|
||||||
|
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||||
|
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||||
|
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||||
|
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||||
|
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||||
|
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||||
|
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||||
|
|
||||||
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
|
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||||
|
assert "info" in zustand, (
|
||||||
|
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
|
||||||
|
"bis zu 15 Sekunden auf 'unbekannt'."
|
||||||
|
)
|
||||||
|
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
|
||||||
|
assert zustand["workers"] == [{"name": "pc"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
|
||||||
|
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
|
||||||
|
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||||
|
monkeypatch.setattr(main.db, "list_workers", lambda: [])
|
||||||
|
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
|
|
||||||
|
async def platzt():
|
||||||
|
raise RuntimeError("Platte weg")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
|
||||||
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||||
|
|
||||||
|
|
||||||
|
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
||||||
|
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
|
||||||
|
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
|
||||||
|
— auf einem PC ohne Container und ohne zweiten Worker.
|
||||||
|
|
||||||
|
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
|
||||||
|
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
|
||||||
|
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
|
||||||
|
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
|
||||||
|
# Lauf 170 rot geworden. /betrieb braucht keine.
|
||||||
|
antwort = TestClient(app).get("/betrieb")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
daten = antwort.json()
|
||||||
|
assert daten["modus"] in ("standalone", "verteilt")
|
||||||
|
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||||
|
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||||
|
"container_pfade", "werkzeuge_verwalten",
|
||||||
|
"frei_blaettern"}
|
||||||
|
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
|
||||||
|
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
|
||||||
|
# der keinen Container hat (Befund 28.08.2026).
|
||||||
|
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
|
||||||
|
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||||
|
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||||
|
if not daten["im_container"]:
|
||||||
|
assert daten["hilfe_befehl"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
|
||||||
|
#
|
||||||
|
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||||
|
#
|
||||||
|
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
|
||||||
|
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
|
||||||
|
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
|
||||||
|
# auch `running` auf `processing` ab.
|
||||||
|
#
|
||||||
|
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
|
||||||
|
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
|
||||||
|
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
|
||||||
|
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
|
||||||
|
|
||||||
|
|
||||||
|
def _echte_db_zeile():
|
||||||
|
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "j1",
|
||||||
|
"disc_type": "bluray", # NICHT "type"
|
||||||
|
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
|
||||||
|
"finished_at": None,
|
||||||
|
"status": "running", # wird zu "processing"
|
||||||
|
"device": r"\.\G:",
|
||||||
|
"progress": 12,
|
||||||
|
"title": "Evangelion 2.22",
|
||||||
|
"error": None,
|
||||||
|
"meta": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
|
||||||
|
import main
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
assert ui["type"] == "bluray"
|
||||||
|
assert ui["startTime"].startswith("2026-08-29")
|
||||||
|
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
|
||||||
|
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
|
||||||
|
Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft."""
|
||||||
|
import main
|
||||||
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
kurz = _job_kurz(ui)
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
|
||||||
|
assert kurz["type"] == "bluray"
|
||||||
|
assert kurz["status"] == "processing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
|
||||||
|
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
|
||||||
|
assert "_job_row_to_model" in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
|
||||||
|
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
|
||||||
|
|
||||||
|
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
|
||||||
|
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
|
||||||
|
(beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der
|
||||||
|
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
|
||||||
|
weiter oben stand.
|
||||||
|
|
||||||
|
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
# Die eine erlaubte Fundstelle ist die Funktion selbst.
|
||||||
|
stellen = quelle.count("device_discovery.device_info(")
|
||||||
|
assert stellen == 1, (
|
||||||
|
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
|
||||||
|
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
|
||||||
|
for name in ("_snapshot", "startup_event", "get_devices"):
|
||||||
|
text = inspect.getsource(getattr(main, name))
|
||||||
|
assert "device_info(" not in text, \
|
||||||
|
"%s baut die Laufwerksliste selbst" % name
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_mit_disc()
|
||||||
|
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
|
||||||
|
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
assert "disc" not in main.laufwerke_mit_disc()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
|
||||||
|
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
|
||||||
|
sein."
|
||||||
|
|
||||||
|
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg —
|
||||||
|
damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und
|
||||||
|
einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
|
||||||
|
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
|
||||||
|
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
|
||||||
|
(gemessen: Disc nach 119 s erkannt).
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert geraet["disc_wird_erkannt"] is True
|
||||||
|
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
|
||||||
|
assert "disc" not in geraet
|
||||||
|
|
||||||
|
|
||||||
|
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert not geraet.get("disc_wird_erkannt")
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
|
||||||
|
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
|
||||||
|
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
assert "disc_wird_erkannt" in main.Device.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
|
||||||
|
#
|
||||||
|
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
|
||||||
|
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
|
||||||
|
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
|
||||||
|
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
|
||||||
|
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||||
|
# sein soll.
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready"}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
|
||||||
|
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
|
||||||
|
„behalte deinen Stand", und das ist die ehrliche Antwort."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
|
assert main.laufwerke_notdurft() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
|
||||||
|
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
|
||||||
|
nur das Abfragen des Laufwerks ist teuer."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
|
||||||
|
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready",
|
||||||
|
"disc_wird_erkannt": True}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_notdurft()[0]
|
||||||
|
assert "disc_wird_erkannt" not in geraet
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
|
||||||
|
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
|
||||||
|
# fenster ganz kurz im hintergrund."
|
||||||
|
#
|
||||||
|
# Beides war dieselbe Schleife: `except OSError: continue` liess
|
||||||
|
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
|
||||||
|
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
|
||||||
|
# naechste Statusabfrage scheitert — und so weiter.
|
||||||
|
|
||||||
|
|
||||||
|
def _CDS():
|
||||||
|
import main
|
||||||
|
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, ok) == "erststart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
|
||||||
|
import main
|
||||||
|
_, leer, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, leer) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
|
||||||
|
"""Der Kern des Befundes."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
|
||||||
|
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
|
||||||
|
„noch nie gesehen" verwechselt, scannt endlos.
|
||||||
|
|
||||||
|
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
|
||||||
|
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
|
||||||
|
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
|
||||||
|
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
|
||||||
|
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
|
||||||
|
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_wird_erkannt():
|
||||||
|
import main
|
||||||
|
ok, leer, offen = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
|
||||||
|
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
|
||||||
|
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
|
||||||
|
weg, sobald das Laufwerk kurz beschaeftigt ist."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, 3) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
|
||||||
|
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.disc_watcher)
|
||||||
|
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
|
||||||
|
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
|
||||||
|
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
gemessen' aber messung wird nicht abgeschlossen."
|
||||||
|
|
||||||
|
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
|
||||||
|
seine Jobs mit dem nackten `_job_row_to_model` — also ohne. Seit V2-3
|
||||||
|
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
|
||||||
|
also brav berechnet und niemandem gezeigt.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main._snapshot)
|
||||||
|
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
|
||||||
|
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_jobliste_traegt_die_restzeit_felder():
|
||||||
|
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
|
||||||
|
assert feld in main.Job.model_fields, feld
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
|
||||||
|
#
|
||||||
|
# Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
|
||||||
|
# bereits bei 30% und er liest immernoch."
|
||||||
|
#
|
||||||
|
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
|
||||||
|
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
|
||||||
|
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
|
||||||
|
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
|
||||||
|
# wissen wir — der Job laeuft ja auf ihr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gescannt = []
|
||||||
|
|
||||||
|
class NieBenutzt:
|
||||||
|
def scan(self, pfad):
|
||||||
|
gescannt.append(pfad)
|
||||||
|
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", NieBenutzt)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert gescannt == []
|
||||||
|
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
class Ergebnis:
|
||||||
|
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"title": "Akira"}
|
||||||
|
|
||||||
|
class Scanner:
|
||||||
|
def scan(self, pfad):
|
||||||
|
return Ergebnis()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", Scanner)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
|
||||||
|
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
|
||||||
|
lambda p: asyncio.sleep(0))
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
|
||||||
|
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
|
||||||
|
Ende stehen — der Scan davor haelt sie ja fest."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
|
||||||
|
if not quelle:
|
||||||
|
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
|
||||||
|
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
|
||||||
|
#
|
||||||
|
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
|
||||||
|
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
|
||||||
|
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
|
||||||
|
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
|
||||||
|
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
|
||||||
|
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
|
||||||
|
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
|
||||||
|
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
|
||||||
|
|
||||||
|
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
|
||||||
|
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_offen_zaehlt_die_kompression_mit():
|
||||||
|
from rippy.store import JOB_OFFEN
|
||||||
|
|
||||||
|
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
|
||||||
|
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
|
||||||
|
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
||||||
|
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
|
||||||
|
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
|
||||||
|
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from rippy import store
|
||||||
|
|
||||||
|
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
|
||||||
|
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
|
||||||
|
|
||||||
|
12:49:52 bluray-Rip gestartet
|
||||||
|
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||||
|
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
|
||||||
|
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
|
||||||
|
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
|
||||||
|
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
|
||||||
|
stand = main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
|
||||||
|
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
|
||||||
|
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
|
||||||
|
main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == ["/dev/sr0"]
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen.
|
||||||
|
|
||||||
|
## Warum es diese Tests gibt (Befund 28.08.2026)
|
||||||
|
|
||||||
|
Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine
|
||||||
|
Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar:
|
||||||
|
|
||||||
|
**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und
|
||||||
|
`clients/thetvdb.py` machten im Konstruktor `from db import get_settings`.
|
||||||
|
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
|
||||||
|
mehr — main.py schreibt seitdem `from rippy import store as db`, diese drei
|
||||||
|
Module haben es nie mitbekommen. Damit starb JEDE Metadaten-Abfrage schon
|
||||||
|
beim Erzeugen des Clients mit `ModuleNotFoundError`, auf Windows UND auf der
|
||||||
|
VM. `_auto_prescan` verschluckte es in seinem `except Exception`.
|
||||||
|
|
||||||
|
**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an — den
|
||||||
|
Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein
|
||||||
|
Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan
|
||||||
|
mit.
|
||||||
|
|
||||||
|
Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht.
|
||||||
|
Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen
|
||||||
|
den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_client_baut_sich_ohne_datenbank():
|
||||||
|
"""Der Test, der den Phantom-Import gefangen haette."""
|
||||||
|
from clients.tmdb import TMDBClient
|
||||||
|
|
||||||
|
klient = TMDBClient()
|
||||||
|
assert hasattr(klient, "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_omdb_client_baut_sich_ohne_datenbank():
|
||||||
|
from clients.omdb import OMDbClient
|
||||||
|
|
||||||
|
assert OMDbClient() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_thetvdb_client_baut_sich_ohne_datenbank():
|
||||||
|
from clients.thetvdb import TheTVDBClient
|
||||||
|
|
||||||
|
assert TheTVDBClient() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_client_importiert_das_verschwundene_db_modul():
|
||||||
|
"""Der Waechter gegen genau diesen Fehler, mechanisch.
|
||||||
|
|
||||||
|
Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc
|
||||||
|
einlegt — und dann als „keine Metadaten", nicht als Import-Fehler.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
fundstellen = []
|
||||||
|
for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")):
|
||||||
|
ordner[:] = [o for o in ordner if o != "__pycache__"]
|
||||||
|
for name in dateien:
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
|
pfad = os.path.join(wurzel, name)
|
||||||
|
with open(pfad, encoding="utf-8") as f:
|
||||||
|
for nr, zeile in enumerate(f, 1):
|
||||||
|
if zeile.strip().startswith(("from db import", "import db")):
|
||||||
|
fundstellen.append("%s:%d" % (name, nr))
|
||||||
|
assert not fundstellen, (
|
||||||
|
"docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt "
|
||||||
|
"rippy.store. Fundstellen: " + ", ".join(fundstellen))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Zwischenspeicher ────────────────────────────────────────────────
|
||||||
|
def test_cache_ohne_redis_liefert_None_statt_zu_werfen():
|
||||||
|
"""Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.
|
||||||
|
|
||||||
|
Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den
|
||||||
|
Pre-Scan mit — und die Disc blieb namenlos.
|
||||||
|
"""
|
||||||
|
from cache import cache
|
||||||
|
|
||||||
|
class ToterRedis:
|
||||||
|
def get(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
def set(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
def setex(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
def delete(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
def flushdb(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
def ping(self, *a, **k):
|
||||||
|
raise cache.RedisError("nicht erreichbar")
|
||||||
|
|
||||||
|
echt = cache.redis_client
|
||||||
|
cache.redis_client = ToterRedis()
|
||||||
|
try:
|
||||||
|
assert cache.get("egal") is None
|
||||||
|
assert cache.set("egal", {"a": 1}) is False
|
||||||
|
assert cache.delete("egal") is False
|
||||||
|
assert cache.clear() is False
|
||||||
|
cache.init_cache() # darf ebenfalls nicht werfen
|
||||||
|
assert cache.zustand()["erreichbar"] is False
|
||||||
|
finally:
|
||||||
|
cache.redis_client = echt
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_meldet_den_ausfall_nur_EINMAL(capsys):
|
||||||
|
"""Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf
|
||||||
|
kaeme sie mehrfach."""
|
||||||
|
from cache import cache
|
||||||
|
|
||||||
|
class ToterRedis:
|
||||||
|
def get(self, *a, **k):
|
||||||
|
raise cache.RedisError("weg")
|
||||||
|
|
||||||
|
echt, zustand = cache.redis_client, cache._erreichbar
|
||||||
|
cache.redis_client = ToterRedis()
|
||||||
|
cache._erreichbar = None
|
||||||
|
try:
|
||||||
|
for _ in range(5):
|
||||||
|
cache.get("x")
|
||||||
|
ausgabe = capsys.readouterr().out
|
||||||
|
assert ausgabe.count("nicht erreichbar") == 1, ausgabe
|
||||||
|
finally:
|
||||||
|
cache.redis_client, cache._erreichbar = echt, zustand
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_kaputter_eintrag_wirft_nicht():
|
||||||
|
"""Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die
|
||||||
|
Antwort eben frisch geholt."""
|
||||||
|
from cache import cache
|
||||||
|
|
||||||
|
class KaputterRedis:
|
||||||
|
def get(self, *a, **k):
|
||||||
|
return "{kein json"
|
||||||
|
|
||||||
|
echt = cache.redis_client
|
||||||
|
cache.redis_client = KaputterRedis()
|
||||||
|
try:
|
||||||
|
assert cache.get("x") is None
|
||||||
|
finally:
|
||||||
|
cache.redis_client = echt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"])
|
||||||
|
def test_jeder_client_ist_importierbar(name):
|
||||||
|
"""Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand
|
||||||
|
eine Disc einlegt."""
|
||||||
|
__import__("clients." + name)
|
||||||
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
|||||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
assert speicher == {} # nichts geschrieben
|
assert speicher == {} # nichts geschrieben
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#
|
||||||
|
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
|
||||||
|
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
|
||||||
|
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
|
||||||
|
# Punkt, und `restzeit_sekunden` braucht zwei.
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_cache():
|
||||||
|
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
|
||||||
|
return (lambda k: None), (lambda k, v, expire=None: False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
|
||||||
|
"""DER Fall des Commanders."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
|
||||||
|
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
|
||||||
|
|
||||||
|
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
|
||||||
|
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_cache_hat_weiterhin_vorrang():
|
||||||
|
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
|
||||||
|
API-Neustart, das Woerterbuch im Prozess nicht."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"j2", "running", 25, 620.0,
|
||||||
|
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
|
||||||
|
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
|
||||||
|
assert ergebnis["sekunden"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
|
||||||
|
"""Rip und Kompression haben nichts miteinander zu tun."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
|
||||||
|
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
|
||||||
|
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alte_reihen_wachsen_nicht_unbegrenzt():
|
||||||
|
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
|
||||||
|
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
|
||||||
|
eta.aktualisiere_und_schaetze("neu", "running", 10,
|
||||||
|
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
|
||||||
|
assert eta.schluessel("alt") not in eta._REIHEN
|
||||||
|
assert eta.schluessel("neu") in eta._REIHEN
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
from makemkv_key import extract_key
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||||
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
|
|||||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
assert treffer == _KEY
|
assert treffer == _KEY
|
||||||
assert treffer.startswith("T-")
|
assert treffer.startswith("T-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
|
||||||
|
#
|
||||||
|
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
|
||||||
|
# > ausgelesen werden, ich glaube das web rippy kann das"
|
||||||
|
#
|
||||||
|
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
|
||||||
|
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
|
||||||
|
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def klient():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
daten = antwort.json()
|
||||||
|
assert daten["geholt"] is True
|
||||||
|
assert daten["geaendert"] is True
|
||||||
|
assert daten["endet_auf"] == _KEY[-6:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
|
||||||
|
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
|
||||||
|
ein zweiter Weg, ihn zu verlieren."""
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
text = klient.post("/system/makemkv-key/holen").text
|
||||||
|
assert _KEY not in text
|
||||||
|
assert _KEY[:20] not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
|
||||||
|
monkeypatch):
|
||||||
|
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
|
||||||
|
def wirft(*a, **k):
|
||||||
|
raise OSError("Verbindung abgelehnt")
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "Forum" in antwort.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
|
||||||
|
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
|
||||||
|
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "geändert" in antwort.json()["detail"]
|
||||||
|
|||||||
@@ -145,7 +145,11 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
|
|||||||
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
|
||||||
# den Containern, deshalb per Pfad laden statt importieren.
|
# den Containern, deshalb per Pfad laden statt importieren.
|
||||||
worker_tasks = os.path.join(
|
worker_tasks = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
|
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
|
||||||
|
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
|
||||||
|
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
|
||||||
|
# gruen, ohne etwas zu beweisen.
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "ablauf.py"
|
||||||
)
|
)
|
||||||
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
|
||||||
quelltext = open(worker_tasks, encoding="utf-8").read()
|
quelltext = open(worker_tasks, encoding="utf-8").read()
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Welche Pfade darf die API anfassen — und wo ist „oben"?
|
||||||
|
|
||||||
|
## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026)
|
||||||
|
|
||||||
|
`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container
|
||||||
|
ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und
|
||||||
|
die Folge war eine Oberflaeche, die stillschweigend nichts konnte:
|
||||||
|
|
||||||
|
* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht),
|
||||||
|
* `/browse` antwortete auf JEDEN Pfad mit 422,
|
||||||
|
* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag.
|
||||||
|
|
||||||
|
Sein Befund dazu: „Wäre es möglich das Arbeitsverzeichnis zu ändern?
|
||||||
|
momentan geht das nicht."
|
||||||
|
|
||||||
|
Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im
|
||||||
|
Netz — eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein
|
||||||
|
Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem
|
||||||
|
Rechner sitzt. Diese Tests halten beide Haelften fest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Grenze gilt, wo zugehoert wird ──────────────────────────────────
|
||||||
|
def test_im_container_gilt_die_wurzel_weiter():
|
||||||
|
"""Der Docker-Weg darf sich durch die Reparatur NICHT lockern."""
|
||||||
|
assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False)
|
||||||
|
assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False)
|
||||||
|
# Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb.
|
||||||
|
assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ist_auch_eine_freigabe_erlaubt():
|
||||||
|
"""Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar
|
||||||
|
keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar."""
|
||||||
|
assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True)
|
||||||
|
assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leerer_pfad_ist_nie_erlaubt():
|
||||||
|
"""Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`."""
|
||||||
|
assert not main.pfad_erlaubt("", "/app/media", frei=True)
|
||||||
|
assert not main.pfad_erlaubt("", "/app/media", frei=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wo ist oben ─────────────────────────────────────────────────────────
|
||||||
|
def test_an_der_wurzel_ist_schluss():
|
||||||
|
"""Im Container. `None` heisst: kein Knopf „nach oben"."""
|
||||||
|
assert main.eltern_von("/app/media", "/app/media", frei=False) is None
|
||||||
|
assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste():
|
||||||
|
"""Zwei Fallen auf einmal.
|
||||||
|
|
||||||
|
`os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` — ein Knopf „nach oben",
|
||||||
|
der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der
|
||||||
|
Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke:
|
||||||
|
Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer
|
||||||
|
100 GB Rohdaten.
|
||||||
|
"""
|
||||||
|
import ntpath
|
||||||
|
import os
|
||||||
|
|
||||||
|
echt = os.path.dirname
|
||||||
|
os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux
|
||||||
|
try:
|
||||||
|
assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == ""
|
||||||
|
assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users"
|
||||||
|
finally:
|
||||||
|
os.path.dirname = echt
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
||||||
|
"""Eine leere Wurzel wuerde jede Pruefung durchwinken."""
|
||||||
|
assert main.medien_wurzel()
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
|
||||||
|
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
|
||||||
|
assert isinstance(main.betrieb_laufwerke(), list)
|
||||||
@@ -55,7 +55,11 @@ def test_der_schluessel_heisst_im_worker_genauso():
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
quelle = (
|
quelle = (
|
||||||
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
|
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
|
||||||
|
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
|
||||||
|
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
|
||||||
|
# gruen, ohne etwas zu beweisen.
|
||||||
|
pathlib.Path(__file__).resolve().parents[1] / "worker" / "ablauf.py"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
|
||||||
# ankommen.
|
# ankommen.
|
||||||
|
|||||||
@@ -144,3 +144,80 @@ def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
|
|||||||
assert ergebnis.confidence == 0.3
|
assert ergebnis.confidence == 0.3
|
||||||
assert ergebnis.metadata["type"] == "unknown"
|
assert ergebnis.metadata["type"] == "unknown"
|
||||||
assert ergebnis.title == "Nix Da"
|
assert ergebnis.title == "Nix Da"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Was auf FREMDEN Rechnern gilt (Befund 28.08.2026) ───────────────────
|
||||||
|
#
|
||||||
|
# Der Commander: "du hast jetzt nur mein laufwerk analysiert oder? wenn ich
|
||||||
|
# rippy weitergebe wie laeuft es dort?"
|
||||||
|
#
|
||||||
|
# Berechtigt. Gemessen wurde an EINEM Laufwerk (LG BU40N) und EINER Disc
|
||||||
|
# (BD-50 mit BDMV/META). Diese Tests halten fest, was bei ANDEREN Discs
|
||||||
|
# passieren muss -- ohne dass eine davon vorhanden sein muss.
|
||||||
|
def test_bluray_ohne_metadaten_gibt_ehrlich_nichts_zurueck(tmp_path):
|
||||||
|
"""Sehr viele Blu-rays haben kein BDMV/META. Dann greift der Rueckfall
|
||||||
|
auf das Volume-Label -- aber diese Funktion darf nichts erfinden."""
|
||||||
|
from prescan.prescan import titel_aus_bdmt
|
||||||
|
|
||||||
|
(tmp_path / "BDMV" / "STREAM").mkdir(parents=True)
|
||||||
|
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvd_hat_kein_bdmv(tmp_path):
|
||||||
|
from prescan.prescan import titel_aus_bdmt
|
||||||
|
|
||||||
|
(tmp_path / "VIDEO_TS").mkdir()
|
||||||
|
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bdmt_wird_gelesen_wenn_da(tmp_path):
|
||||||
|
"""Der Weg, der bei der Disc des Commanders 'Evangelion 2.22' lieferte —
|
||||||
|
hier ohne Disc nachgestellt."""
|
||||||
|
from prescan.prescan import titel_aus_bdmt
|
||||||
|
|
||||||
|
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||||
|
meta.mkdir(parents=True)
|
||||||
|
(meta / "bdmt_deu.xml").write_text(
|
||||||
|
'<?xml version="1.0"?><disclib><di:discinfo>'
|
||||||
|
'<di:title><di:name>Evangelion 2.22</di:name></di:title>'
|
||||||
|
'</di:discinfo></disclib>', encoding="utf-8")
|
||||||
|
assert titel_aus_bdmt(str(tmp_path)) == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_englisch_wird_bevorzugt(tmp_path):
|
||||||
|
"""Die APIs suchen mit englischen Titeln besser."""
|
||||||
|
from prescan.prescan import titel_aus_bdmt
|
||||||
|
|
||||||
|
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||||
|
meta.mkdir(parents=True)
|
||||||
|
for datei, name in (("bdmt_deu.xml", "Deutscher Titel"),
|
||||||
|
("bdmt_eng.xml", "English Title")):
|
||||||
|
(meta / datei).write_text(
|
||||||
|
"<x><di:name>%s</di:name></x>" % name, encoding="utf-8")
|
||||||
|
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||||
|
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||||
|
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||||
|
Programme liegen in „Programme", nicht im PATH) und war unter Docker
|
||||||
|
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
|
||||||
|
der Aufruf noch die shutil.which-Suche zurueckkommen.
|
||||||
|
|
||||||
|
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden` —
|
||||||
|
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
|
||||||
|
die Entfernung deshalb erst dort gemeldet.)"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
with open(os.path.join(hier, "prescan", "prescan.py"), encoding="utf-8") as f:
|
||||||
|
# NUR Code, keine Kommentare: Der erste Anlauf dieses Tests fiel ueber
|
||||||
|
# den eigenen Erklaertext, in dem der alte Aufruf zitiert wird. Ein
|
||||||
|
# Waechter, der an einer Erklaerung scheitert, prueft das Falsche.
|
||||||
|
code = [z for z in f if not z.strip().startswith("#")]
|
||||||
|
quelle = "".join(code)
|
||||||
|
assert "shutil.which(" not in quelle, (
|
||||||
|
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||||
|
assert "makemkvcon" not in quelle, (
|
||||||
|
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||||
|
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (28.08.2026)
|
||||||
|
|
||||||
|
> „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
|
||||||
|
> cover."
|
||||||
|
|
||||||
|
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
|
||||||
|
sondern eine Bedingung im Pre-Scan:
|
||||||
|
|
||||||
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
|
|
||||||
|
An seiner Disc gemessen:
|
||||||
|
|
||||||
|
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||||
|
'Evangelion' 20 Treffer irgendein Evangelion
|
||||||
|
|
||||||
|
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
|
||||||
|
Poster — und wurde verworfen, weil der Titel nicht woertlich gleich war.
|
||||||
|
|
||||||
|
## Warum die Trefferzahl und nicht die Aehnlichkeit
|
||||||
|
|
||||||
|
„Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
|
||||||
|
Aehnlichkeit — das steht als Messung schon laenger im Code und faellt durch
|
||||||
|
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
|
||||||
|
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
|
||||||
|
weiss was er sucht. Wer 20 bekommt, hat geraten.
|
||||||
|
|
||||||
|
Diese Tests spritzen die Clients ein — kein Netz, keine Schluessel, keine
|
||||||
|
Disc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTMDB:
|
||||||
|
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
|
||||||
|
|
||||||
|
def __init__(self, treffer_je_suche, details=None):
|
||||||
|
self.treffer = treffer_je_suche
|
||||||
|
self.details = details or {}
|
||||||
|
self.gefragt = []
|
||||||
|
|
||||||
|
def search_movie(self, begriff):
|
||||||
|
self.gefragt.append(begriff)
|
||||||
|
return self.treffer.get(begriff, [])
|
||||||
|
|
||||||
|
def search_tv(self, begriff):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_movie_details(self, kennung):
|
||||||
|
return self.details.get(kennung)
|
||||||
|
|
||||||
|
def get_tv_details(self, kennung):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_by_imdb(self, kennung):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StummerClient:
|
||||||
|
def lookup(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DETAILS_2_0 = {
|
||||||
|
22843: {
|
||||||
|
"title": "Evangelion: 2.0 You Can (Not) Advance",
|
||||||
|
"release_date": "2009-06-27",
|
||||||
|
"overview": "Der Pilotin Mari gelingt es …",
|
||||||
|
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
|
||||||
|
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
|
||||||
|
"runtime": 112,
|
||||||
|
"genres": [{"name": "Animation"}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prescan(tmdb):
|
||||||
|
from prescan.prescan import PreScan
|
||||||
|
|
||||||
|
p = PreScan()
|
||||||
|
p.tmdb = tmdb
|
||||||
|
p.jikan = StummerClient()
|
||||||
|
p.omdb = StummerClient()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _toc(titel):
|
||||||
|
return {"title": titel, "year": None, "tracks": [], "duration": 0,
|
||||||
|
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
|
||||||
|
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Evangelion 2.22": [{"id": 22843,
|
||||||
|
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
|
||||||
|
DETAILS_2_0)
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||||
|
|
||||||
|
assert ergebnis.confidence == 0.8
|
||||||
|
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
|
||||||
|
assert ergebnis.year == 2009
|
||||||
|
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
|
||||||
|
assert ergebnis.metadata["source"] == "tmdb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_woertlich_gleicher_titel_bleibt_sicherer():
|
||||||
|
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Logan": [{"id": 22843, "title": "Logan"}]},
|
||||||
|
{22843: dict(DETAILS_2_0[22843], title="Logan")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
|
||||||
|
assert ergebnis.confidence == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
|
||||||
|
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
|
||||||
|
werden. Es bleibt beim ehrlichen Vorschlag."""
|
||||||
|
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
|
||||||
|
tmdb = FakeTMDB({"Evangelion": viele},
|
||||||
|
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
|
||||||
|
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
|
||||||
|
|
||||||
|
|
||||||
|
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
|
||||||
|
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
|
||||||
|
|
||||||
|
Sonst wuerde „Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
|
||||||
|
„Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
|
||||||
|
"""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
|
||||||
|
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||||
|
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
|
||||||
|
assert ergebnis.confidence == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
|
||||||
|
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
|
||||||
|
assert ergebnis.confidence == 0.3
|
||||||
|
assert ergebnis.metadata["type"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
|
||||||
|
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
|
||||||
|
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
|
||||||
|
desselben Films, aber eng genug, um Raten auszuschliessen."""
|
||||||
|
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
|
||||||
|
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
|
||||||
|
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
|
||||||
|
assert ergebnis.confidence == erwartet
|
||||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
|||||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||||
|
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||||
|
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||||
|
# prompt sieben Tests rot geworden).
|
||||||
|
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||||
|
|
||||||
|
|
||||||
def test_suche_liefert_nur_was_existiert():
|
def test_suche_liefert_nur_was_existiert():
|
||||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["movies", "rippy"],
|
listdir=lambda p: ["movies", "rippy"],
|
||||||
isdir=lambda p: p in vorhanden,
|
isdir=lambda p: p in vorhanden,
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
|||||||
assert f"/app/media/{JOB}" in orte
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||||
|
#: Gegenstück zu CONTAINER weiter oben.
|
||||||
|
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||||
|
|
||||||
|
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||||
|
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||||
|
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||||
|
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||||
|
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||||
|
beschädigten Datenträger für etwas, das schon dalag.
|
||||||
|
"""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||||
|
assert "F:" + chr(92) + JOB in orte
|
||||||
|
assert "F:" + JOB not in orte
|
||||||
|
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||||
|
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||||
|
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||||
|
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||||
|
nicht am Trenner scheitern."""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||||
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_job_id_nichts():
|
def test_ohne_job_id_nichts():
|
||||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||||
|
|
||||||
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
|||||||
return p == f"/app/temp/raw/{JOB}"
|
return p == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
|||||||
raise OSError("kein /app/media")
|
raise OSError("kein /app/media")
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
|||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
|||||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [], "unklar": True}
|
assert e == {"pfade": [], "unklar": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
|
|||||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
|||||||
return "unklar" if "totes-nas" in pfad else "weg"
|
return "unklar" if "totes-nas" in pfad else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
assert e["unklar"] is True
|
assert e["unklar"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
|
||||||
|
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
|
||||||
|
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
|
||||||
|
14:54:40 WindowsTerminal.exe
|
||||||
|
|
||||||
|
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
|
||||||
|
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
|
||||||
|
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
|
||||||
|
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
|
||||||
|
die Antwort weg fuer JEDES Verzeichnis.
|
||||||
|
"""
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: gestartet.append(a))
|
||||||
|
assert rohdaten.pruefen(str(tmp_path)) == "da"
|
||||||
|
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
|
||||||
|
assert gestartet == [], "es darf kein Prozess gestartet werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_bleibt_der_kindprozess(monkeypatch):
|
||||||
|
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
|
||||||
|
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
class Antwort:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
|
||||||
|
assert rohdaten.pruefen("/app/media/x") == "da"
|
||||||
|
assert aufrufe[0][0] == "timeout"
|
||||||
|
assert aufrufe[0][-1] == "/app/media/x"
|
||||||
|
|||||||
@@ -6,19 +6,91 @@ import LogsPage from './pages/Logs'
|
|||||||
import AnleitungPage from './pages/Anleitung'
|
import AnleitungPage from './pages/Anleitung'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
|
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||||
|
import { BetriebProvider } from './lib/useBetrieb'
|
||||||
|
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
export default function App() {
|
/**
|
||||||
|
* Zeigt, ob der Live-Strom steht.
|
||||||
|
*
|
||||||
|
* Hier stand bis V2-3 ein fest verdrahtetes „ONLINE" mit pulsierendem Punkt —
|
||||||
|
* es leuchtete auch dann gruen, wenn die API tot war. Genau die Sorte
|
||||||
|
* Placebo-Anzeige, die in Etappe 19 schon einmal aufgeraeumt wurde
|
||||||
|
* („Fortschritt log, Auswurf tat nichts, Encoder wurden behauptet statt
|
||||||
|
* gemessen"). Jetzt sagt sie die Wahrheit.
|
||||||
|
*/
|
||||||
|
function VerbindungsAnzeige() {
|
||||||
|
const { verbunden, zuletzt } = useStrom()
|
||||||
|
if (verbunden) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
<span>LIVE</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-xs font-mono text-amber-400 bg-amber-500/10 px-3 py-1.5 rounded-xl border border-amber-500/30"
|
||||||
|
title={
|
||||||
|
zuletzt
|
||||||
|
? `Letzte Meldung um ${zuletzt.toLocaleTimeString()}. Die Anzeige zeigt diesen Stand, bis die Verbindung zurueck ist.`
|
||||||
|
: 'Noch keine Verbindung zum Server.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>VERBINDUNG WEG</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInhalt() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ⚠️ Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung.
|
||||||
|
*
|
||||||
|
* Hier stand `.catch(() => setSetupDone(true))` mit der Begründung
|
||||||
|
* „API/Setup nicht erreichbar → nicht blockieren". Die Folge hat der
|
||||||
|
* Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation
|
||||||
|
* fehlte der Ersteinrichtungs-Assistent.**
|
||||||
|
*
|
||||||
|
* Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf,
|
||||||
|
* der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort — der
|
||||||
|
* erste Abruf scheitert also fast immer. Aus „ich konnte nicht nachsehen"
|
||||||
|
* wurde „ist schon erledigt", und der Assistent war für immer weg.
|
||||||
|
*
|
||||||
|
* Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues
|
||||||
|
* weiß, behält, was er wusste — und behauptet nichts.
|
||||||
|
*
|
||||||
|
* Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort
|
||||||
|
* entscheidet.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let abgemeldet = false
|
||||||
|
let versuche = 0
|
||||||
|
|
||||||
|
const fragen = () => {
|
||||||
api.get('/setup')
|
api.get('/setup')
|
||||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
|
||||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
.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">
|
||||||
|
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||||
|
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||||
|
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||||
|
Bildschirm leer — samt Kopfzeile. */}
|
||||||
|
<Fehlergrenze bereich={
|
||||||
|
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||||
|
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||||
|
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||||
{currentPage === 'dashboard' && <Dashboard />}
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
{currentPage === 'logs' && <LogsPage />}
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
{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>
|
<p>
|
||||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
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
|
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
<p>
|
<p>
|
||||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
Einstellungen → Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
|
||||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
|
||||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
|
||||||
jedem Rip und bricht sonst mit Klartext ab.
|
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>
|
||||||
|
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
|
||||||
|
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen →
|
||||||
|
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
|
||||||
|
Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
|
||||||
</p>
|
</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[])
|
||||||
|
|
||||||
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
|
|
||||||
const schnellLaden = async () => {
|
|
||||||
try {
|
|
||||||
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)
|
setLoading(false)
|
||||||
}
|
}, [strom.jobs])
|
||||||
}
|
|
||||||
|
|
||||||
// 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.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
|
||||||
const langsamLaden = async () => {
|
}, [strom.devices])
|
||||||
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.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
|
||||||
const schnell = setInterval(schnellLaden, 4000)
|
}, [strom.workers, strom.verbunden])
|
||||||
const langsam = setInterval(langsamLaden, 12000)
|
|
||||||
return () => { clearInterval(schnell); clearInterval(langsam) }
|
useEffect(() => {
|
||||||
}, [])
|
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
|
||||||
|
}, [strom.systemInfo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
|
||||||
|
}, [strom.ablagen])
|
||||||
|
|
||||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
const queueJobs = jobs.filter(j => j.status === 'pending')
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
@@ -343,6 +332,12 @@ 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"'
|
||||||
|
// 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'
|
: 'Bereit — kein Datenträger im Laufwerk'
|
||||||
|
|
||||||
const filteredJobs = jobs.filter(j => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
@@ -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">
|
<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" />
|
||||||
|
{ort.name}:
|
||||||
|
</span>
|
||||||
|
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
|
||||||
|
? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
|
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ort.pfad && (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
|
||||||
|
{ort.pfad}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!betrieb.kann.container_pfade
|
||||||
|
&& (systemInfo?.plaetze || []).length > 1
|
||||||
|
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
|
||||||
|
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
|
||||||
|
statt die zweite Zeile wegzulassen. */
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 mb-1">
|
||||||
|
Beide auf demselben Laufwerk — der Platz wird geteilt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
|
||||||
|
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
|
Container-Platte:
|
||||||
</span>
|
</span>
|
||||||
<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,6 +1336,20 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||||||
|
|
||||||
|
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||||||
|
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||||||
|
glaube das web rippy kann das." Er hatte recht:
|
||||||
|
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||||||
|
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||||||
|
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||||||
|
Eine Automatik, die man nicht sehen kann, ist für den
|
||||||
|
Benutzer keine.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
label="MakeMKV Beta-Key"
|
label="MakeMKV Beta-Key"
|
||||||
@@ -1195,6 +1357,23 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
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,
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -389,7 +438,21 @@ def werkzeug_versionen() -> dict:
|
|||||||
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)
|
|
||||||
)
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -17,7 +17,54 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
# Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
|
||||||
|
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
|
||||||
|
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
|
||||||
|
#
|
||||||
|
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
|
||||||
|
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
|
||||||
|
from rippy.drives.linux import ( # noqa: F401
|
||||||
|
AUSWURF_WARTEN_SEKUNDEN,
|
||||||
|
CDROM_DRIVE_STATUS,
|
||||||
|
CDROM_LOCKDOOR,
|
||||||
|
CDROMCLOSETRAY,
|
||||||
|
CDROMEJECT,
|
||||||
|
CDS_DISC_OK,
|
||||||
|
CDS_DRIVE_NOT_READY,
|
||||||
|
CDS_NO_DISC,
|
||||||
|
CDS_TRAY_OPEN,
|
||||||
|
_auswurf_geglueckt,
|
||||||
|
)
|
||||||
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||||
|
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||||
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeug(name: str) -> str:
|
||||||
|
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
|
||||||
|
|
||||||
|
## Warum hier nicht mehr `shutil.which` steht
|
||||||
|
|
||||||
|
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
|
||||||
|
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das — dort liegen
|
||||||
|
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
|
||||||
|
beide installiert sind: Windows-Programme liegen in Program Files und
|
||||||
|
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen —
|
||||||
|
MakeMKV lag da, und `shutil.which` sah es nicht.
|
||||||
|
|
||||||
|
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
|
||||||
|
der Unterschied zwischen "laeuft" und "laeuft nicht".
|
||||||
|
|
||||||
|
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
|
||||||
|
damit NICHTS — subprocess findet das Programm dort ueber den PATH wie
|
||||||
|
bisher.
|
||||||
|
"""
|
||||||
|
return werkzeuge.finden(name) or _NACKTER_NAME[name]
|
||||||
|
|
||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
@@ -27,114 +74,9 @@ class RipAbbruch(Exception):
|
|||||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||||
|
|
||||||
|
|
||||||
# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py
|
|
||||||
CDROMEJECT = 0x5309
|
|
||||||
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
|
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
|
||||||
CDROMCLOSETRAY = 0x5319
|
|
||||||
|
|
||||||
# Antworten von CDROM_DRIVE_STATUS (cdrom.h)
|
|
||||||
CDS_NO_DISC = 1
|
|
||||||
CDS_TRAY_OPEN = 2
|
|
||||||
CDS_DRIVE_NOT_READY = 3
|
|
||||||
CDS_DISC_OK = 4
|
|
||||||
|
|
||||||
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
|
|
||||||
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
|
|
||||||
AUSWURF_WARTEN_SEKUNDEN = 5
|
|
||||||
|
|
||||||
|
|
||||||
def _auswurf_geglueckt(status: int) -> bool:
|
|
||||||
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
|
|
||||||
|
|
||||||
Sowohl „Schublade offen" als auch „kein Datenträger" zählen: Ein
|
|
||||||
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
|
|
||||||
"""
|
|
||||||
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
|
|
||||||
|
|
||||||
|
|
||||||
def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None,
|
|
||||||
schliessen=None, warten=None) -> bool:
|
|
||||||
"""Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE.
|
|
||||||
|
|
||||||
## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen)
|
|
||||||
|
|
||||||
Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert
|
|
||||||
nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt:
|
|
||||||
|
|
||||||
wirf_disc_aus("/dev/sr0") → True
|
|
||||||
CDROM_DRIVE_STATUS danach → 4 (Disc drin)
|
|
||||||
|
|
||||||
Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV
|
|
||||||
verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und
|
|
||||||
entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf
|
|
||||||
trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides:
|
|
||||||
erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc:
|
|
||||||
|
|
||||||
CDROM_LOCKDOOR 0 + CDROMEJECT → Status 2 (SCHUBLADE OFFEN)
|
|
||||||
|
|
||||||
## Und deshalb wird das Ergebnis geprüft, nicht geglaubt
|
|
||||||
|
|
||||||
Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier
|
|
||||||
„Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung
|
|
||||||
wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im
|
|
||||||
Log stand „Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts;
|
|
||||||
gefragt wird jetzt das Laufwerk.
|
|
||||||
|
|
||||||
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
|
|
||||||
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
|
|
||||||
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
|
|
||||||
|
|
||||||
Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
|
|
||||||
"""
|
|
||||||
if ioctl_fn is None:
|
|
||||||
try:
|
|
||||||
from fcntl import ioctl
|
|
||||||
except ImportError: # Windows — dieser Worker rippt nie
|
|
||||||
return False
|
|
||||||
ioctl_fn = ioctl
|
|
||||||
if warten is None:
|
|
||||||
import time
|
|
||||||
warten = time.sleep
|
|
||||||
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
|
|
||||||
schliessen = schliessen or os.close
|
|
||||||
|
|
||||||
try:
|
|
||||||
fd = oeffnen(device_path)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
|
|
||||||
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
|
|
||||||
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
|
|
||||||
try:
|
|
||||||
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ioctl_fn(fd, CDROMEJECT, 0)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
|
|
||||||
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
|
||||||
try:
|
|
||||||
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
warten(1)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
schliessen(fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def check_makemkv_installed() -> bool:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
return bool(werkzeuge.finden("makemkv"))
|
||||||
|
|
||||||
|
|
||||||
def check_abcde_installed() -> bool:
|
def check_abcde_installed() -> bool:
|
||||||
@@ -147,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
|
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||||
|
#:
|
||||||
|
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||||
|
#: deutschsprachiges MakeMKV):
|
||||||
|
#:
|
||||||
|
#: Encountered 29 errors of type 'Read Error' - see
|
||||||
|
#: http://www.makemkv.com/errors/read/
|
||||||
|
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||||
|
#:
|
||||||
|
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||||
|
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||||
|
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||||
|
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||||
|
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||||
|
#: hierher statt dieser Textsuche.
|
||||||
|
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -158,12 +118,12 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
|||||||
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
werkzeug("makemkv"),
|
||||||
"-r",
|
"-r",
|
||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
makemkv_quelle(device_path),
|
||||||
str(titel),
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
@@ -318,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
|||||||
wäre eine Minute Wartezeit für nichts.
|
wäre eine Minute Wartezeit für nichts.
|
||||||
"""
|
"""
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
ausgabe = ergebnis.stdout or ""
|
ausgabe = text_von(ergebnis.stdout)
|
||||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
alle_dateien = []
|
alle_dateien = []
|
||||||
|
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||||
|
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||||
|
lesefehler = False
|
||||||
for index, nr in enumerate(titel_liste):
|
for index, nr in enumerate(titel_liste):
|
||||||
def anteilig(p, _i=index):
|
def anteilig(p, _i=index):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -339,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
ergebnis["lesefehler"] = lesefehler
|
||||||
return ergebnis
|
return ergebnis
|
||||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||||
|
"lesefehler": lesefehler}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict, meta: dict = None):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
@@ -391,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
|||||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return {}
|
return {}
|
||||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||||
|
|
||||||
|
|
||||||
def parse_scan_dauer(ausgabe: str) -> int:
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
@@ -420,8 +386,8 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -467,7 +433,7 @@ def get_progress_from_prgv(line: str) -> int:
|
|||||||
|
|
||||||
def check_handbrake_installed() -> bool:
|
def check_handbrake_installed() -> bool:
|
||||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
"""Prüft, ob HandBrakeCLI installiert ist."""
|
||||||
return shutil.which("HandBrakeCLI") is not None
|
return bool(werkzeuge.finden("handbrake"))
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
||||||
@@ -535,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
|||||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||||
|
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||||
|
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||||
|
".mov": "av_mov", ".webm": "av_webm"}
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||||
preset: str = DEFAULT_HB_PRESET,
|
preset: str = DEFAULT_HB_PRESET,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||||
@@ -560,20 +532,55 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||||
|
|
||||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
"HandBrakeCLI",
|
werkzeug("handbrake"),
|
||||||
"--input", input_path,
|
"--input", input_path,
|
||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--preset", preset,
|
||||||
]
|
]
|
||||||
|
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||||
|
# endete mit Code 0"
|
||||||
|
#
|
||||||
|
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||||
|
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||||
|
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||||
|
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||||
|
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||||
|
# nichts und nannte das „fehlgeschlagen".
|
||||||
|
#
|
||||||
|
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||||
|
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||||
|
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||||
|
# die auseinanderlaufen koennen.
|
||||||
|
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||||
|
if format_name:
|
||||||
|
befehl += ["--format", format_name]
|
||||||
audio = [s for s in (audio_sprachen or []) if s]
|
audio = [s for s in (audio_sprachen or []) if s]
|
||||||
if audio:
|
if audio:
|
||||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||||
befehl.append("--first-audio")
|
befehl.append("--first-audio")
|
||||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||||
|
#
|
||||||
|
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||||
|
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||||
|
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||||
|
#
|
||||||
|
# unknown option (--audio-codec)
|
||||||
|
# HandBrake has exited. $? = 0
|
||||||
|
#
|
||||||
|
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||||
|
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||||
|
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||||
|
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||||
|
# festschreibt, statt ihn zu finden.
|
||||||
|
#
|
||||||
|
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||||
|
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||||
if untertitel:
|
if untertitel:
|
||||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||||
@@ -638,6 +645,23 @@ def sprachliste(wert) -> list:
|
|||||||
return sauber
|
return sauber
|
||||||
|
|
||||||
|
|
||||||
|
def _datei_daneben(erwartet: str) -> str:
|
||||||
|
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||||
|
|
||||||
|
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||||
|
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||||
|
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||||
|
HandBrake ueberhaupt schreiben kann.
|
||||||
|
"""
|
||||||
|
ordner = os.path.dirname(erwartet)
|
||||||
|
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||||
|
for endung in FORMATE:
|
||||||
|
kandidat = os.path.join(ordner, stamm + endung)
|
||||||
|
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||||
|
return kandidat
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||||
progress_cb=None, abbruch_cb=None,
|
progress_cb=None, abbruch_cb=None,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||||
@@ -661,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
audio_sprachen, untertitel_sprachen),
|
audio_sprachen, untertitel_sprachen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
|
**HB_LESEN,
|
||||||
)
|
)
|
||||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -690,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
|||||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||||
|
HB_ZEILEN_PUFFER = 12
|
||||||
|
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||||
|
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||||
|
HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannter_schalter(zeile: str) -> str:
|
||||||
|
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||||
|
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||||
|
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||||
|
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||||
|
Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited.
|
||||||
|
$? = 0
|
||||||
|
|
||||||
|
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||||
|
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||||
|
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||||
|
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||||
|
|
||||||
|
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||||
|
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||||
|
statt wieder einen ganzen Rip zu kosten.
|
||||||
|
"""
|
||||||
|
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||||
|
return treffer.group(1).strip() if treffer else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hb_schluss(zeilen) -> str:
|
||||||
|
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||||
|
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||||
|
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||||
|
Ausgabe, nur hoerte niemand zu.
|
||||||
|
"""
|
||||||
|
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||||
|
if not sauber:
|
||||||
|
return ""
|
||||||
|
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
falsches_preset = ""
|
falsches_preset = ""
|
||||||
|
falscher_schalter = ""
|
||||||
|
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||||
|
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||||
|
letzte_zeilen = []
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -701,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if line.strip():
|
||||||
|
letzte_zeilen.append(line)
|
||||||
|
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||||
|
del letzte_zeilen[0]
|
||||||
if not falsches_preset:
|
if not falsches_preset:
|
||||||
falsches_preset = unbekanntes_preset(line)
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
if not falscher_schalter:
|
||||||
|
falscher_schalter = unbekannter_schalter(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -718,6 +805,57 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
|
||||||
|
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||||
|
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||||
|
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||||
|
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||||
|
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||||
|
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||||
|
if falscher_schalter:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||||
|
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||||
|
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||||
|
+ hb_schluss(letzte_zeilen)
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||||
|
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||||
|
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||||
|
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||||
|
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||||
|
#
|
||||||
|
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||||
|
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||||
|
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||||
|
if process.returncode == 0:
|
||||||
|
daneben = _datei_daneben(output_path)
|
||||||
|
if daneben:
|
||||||
|
return {"status": "success", "output_path": daneben,
|
||||||
|
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||||
|
"(der Container kommt aus dem Preset)."
|
||||||
|
% (os.path.basename(daneben),
|
||||||
|
os.path.basename(output_path))}
|
||||||
|
# Frueher stand hier geraten „Meist ist der Zielordner nicht
|
||||||
|
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
|
||||||
|
# und leer, der Grund ein falscher Schalter) — und die Vermutung
|
||||||
|
# schickte die Suche in die falsche Richtung. Jetzt wird der
|
||||||
|
# Ordner GENANNT und HandBrake selbst zitiert.
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||||
|
"entstanden (Zielordner: %s)."
|
||||||
|
% os.path.dirname(output_path)
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
|
"return_code": 0,
|
||||||
|
}
|
||||||
if falsches_preset:
|
if falsches_preset:
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -732,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
def disc_fehlt(device_path: str, zustand=None) -> str:
|
||||||
|
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
|
||||||
|
|
||||||
|
## Warum vorher nachgesehen wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander bekam:
|
||||||
|
|
||||||
|
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||||
|
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||||
|
|
||||||
|
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
|
||||||
|
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
|
||||||
|
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
|
||||||
|
und meldet einen Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
|
||||||
|
|
||||||
|
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
|
||||||
|
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
|
||||||
|
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||||
|
"""
|
||||||
|
if zustand is None:
|
||||||
|
try:
|
||||||
|
from rippy import drives as _schicht
|
||||||
|
|
||||||
|
zustand = _schicht.treiber().drive_status
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
stand = zustand(device_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
if stand == CDS_NO_DISC:
|
||||||
|
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
|
||||||
|
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
|
||||||
|
"wieder hinein." % device_path)
|
||||||
|
if stand == CDS_TRAY_OPEN:
|
||||||
|
return "Die Schublade von %s ist offen." % device_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||||
log_cb=None) -> dict:
|
log_cb=None) -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||||
@@ -784,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
|
fehlt = disc_fehlt(device_path)
|
||||||
|
if fehlt:
|
||||||
|
return {"status": "error", "error": fehlt}
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -791,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
build_makemkv_cmd(device_path, output_dir, titel),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||||
bufsize=1,
|
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
|
||||||
|
# schrieb aber UTF-8 — daraus wurde beim Commander
|
||||||
|
# „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
|
||||||
|
# nicht (Python warnt und puffert doch). Ungepuffert kommt
|
||||||
|
# jede Fortschrittszeile sofort an — bei einem Rip, der
|
||||||
|
# Stunden laeuft, ist das der Unterschied zwischen einem
|
||||||
|
# Balken und einem eingefrorenen Fenster.
|
||||||
|
bufsize=0,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||||
|
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||||
|
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||||
|
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||||
|
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||||
|
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||||
|
lesefehler = False
|
||||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
# (Befund 24.07., Summer-Wars-UHD).
|
# (Befund 24.07., Summer-Wars-UHD).
|
||||||
kritische_meldungen = []
|
kritische_meldungen = []
|
||||||
|
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
|
||||||
|
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
|
||||||
|
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
|
||||||
|
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
|
||||||
|
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
|
||||||
|
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
|
||||||
KRITISCH = (
|
KRITISCH = (
|
||||||
"volume key is unknown",
|
"volume key is unknown",
|
||||||
"evaluation period has expired",
|
"evaluation period has expired",
|
||||||
@@ -809,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"Too old version",
|
"Too old version",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for rohzeile in process.stdout:
|
||||||
|
line = text_von(rohzeile)
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0:
|
if progress >= 0:
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -819,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if meldung is None:
|
if meldung is None:
|
||||||
continue
|
continue
|
||||||
code, letzte_meldung = meldung
|
code, letzte_meldung = meldung
|
||||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
if LESEFEHLER_MARKE in letzte_meldung:
|
||||||
|
lesefehler = True
|
||||||
|
if code in KRITISCHE_CODES:
|
||||||
|
kritische_meldungen.append(
|
||||||
|
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||||
|
elif any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
kritische_meldungen.append(letzte_meldung)
|
kritische_meldungen.append(letzte_meldung)
|
||||||
if log_cb:
|
if log_cb:
|
||||||
try:
|
try:
|
||||||
@@ -847,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"files": mkv_dateien,
|
"files": mkv_dateien,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
|
"lesefehler": lesefehler,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"lesefehler": lesefehler,
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
@@ -899,10 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
|
||||||
|
|
||||||
|
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||||
|
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||||
|
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||||
|
|
||||||
|
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||||
|
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||||
|
"""
|
||||||
|
from rippy.drives import windows as win
|
||||||
|
from rippy.rip import audio_cd
|
||||||
|
|
||||||
|
class Leser:
|
||||||
|
toc = staticmethod(win.audio_toc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||||
|
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||||
|
fortschritt)
|
||||||
|
|
||||||
|
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||||
|
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||||
|
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||||
|
# Disc, die niemand kennt.
|
||||||
|
from rippy.rip import musicbrainz_cd
|
||||||
|
|
||||||
|
try:
|
||||||
|
spuren = win.audio_toc(device_path)
|
||||||
|
antwort = musicbrainz_cd.abfragen(
|
||||||
|
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
antwort = None
|
||||||
|
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||||
|
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||||
|
|
||||||
|
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||||
|
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||||
|
werkzeuge.finden("flac"),
|
||||||
|
fortschritt=progress_cb, tags_je_spur=tags)
|
||||||
|
if auswerfen and ergebnis.get("status") == "success":
|
||||||
|
wirf_disc_aus(device_path)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||||
auswerfen: bool = True) -> dict:
|
auswerfen: bool = True) -> dict:
|
||||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||||
Auswurf" (abcde macht das selbst per -x)."""
|
Auswurf" (abcde macht das selbst per -x)."""
|
||||||
|
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||||
|
#
|
||||||
|
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||||
|
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||||
|
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||||
|
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||||
|
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||||
|
#
|
||||||
|
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||||
|
# Wochen so im SAVEPOINT.
|
||||||
|
if os.name == "nt":
|
||||||
|
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||||
|
auswerfen)
|
||||||
|
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
||||||
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
||||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
|||||||
for pfad in fest:
|
for pfad in fest:
|
||||||
if os.path.isfile(pfad):
|
if os.path.isfile(pfad):
|
||||||
return pfad
|
return pfad
|
||||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||||
|
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||||
|
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||||
|
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||||
|
from rippy.tools import katalog
|
||||||
|
|
||||||
|
return (katalog.finden("makemkv")
|
||||||
|
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||||
|
|
||||||
|
|
||||||
def parse_laufwerke(ausgabe: str) -> list:
|
def parse_laufwerke(ausgabe: str) -> list:
|
||||||
|
|||||||
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
|||||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||||
|
#
|
||||||
|
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||||
|
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||||
|
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||||
|
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||||
|
# Freigabe gibt.
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_ist_nichts_extern():
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({}, container=True) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||||
|
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||||
|
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||||
|
container=False) == "ja"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
|
|||||||
zwei noch nie angelegte Ordner.
|
zwei noch nie angelegte Ordner.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
|
|
||||||
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
|
||||||
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
|||||||
return tasks._erreichbarkeit_pruefen(
|
return tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||||
|
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||||
|
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||||
|
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||||
|
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||||
|
fremd=not im_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
|||||||
meldung = tasks._erreichbarkeit_pruefen(
|
meldung = tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||||
"/app/media/movies/X", "/app/media/movies/X",
|
"/app/media/movies/X", "/app/media/movies/X",
|
||||||
|
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||||
|
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||||
|
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||||
|
fremd=True,
|
||||||
)
|
)
|
||||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||||
assert "von keinem Eintrag übersetzt" in meldung
|
assert "von keinem Eintrag übersetzt" in meldung
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def test_matche_episoden_ohne_treffer_gibt_none():
|
|||||||
|
|
||||||
|
|
||||||
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||||
from tasks import pfad_lokal
|
from ablauf import pfad_lokal
|
||||||
|
|
||||||
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
|
||||||
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
|
||||||
@@ -121,30 +121,39 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||||
|
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||||
|
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||||
|
# prompt rot geworden).
|
||||||
|
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||||
|
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
niemand gefragt wird.
|
niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
einst = {"workDir": "/app/media/movies"}
|
einst = {"workDir": "/app/media/movies"}
|
||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
|
||||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
„/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
||||||
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
||||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|||||||
|
|
||||||
|
|
||||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
import tasks
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
w = CONTAINER
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||||
|
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||||
|
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||||
|
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||||
|
# sogar eine AUSDRUECKLICHE Wahl.
|
||||||
|
#
|
||||||
|
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||||
|
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||||
|
# ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||||
|
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||||
|
deshalb still verworfen."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||||
|
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||||
|
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||||
|
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||||
|
Wurzel."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
unc = r"\\192.168.179.62\rippy\movies"
|
||||||
|
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||||
|
assert ziel.startswith(NATIV[0])
|
||||||
|
assert "/app/" not in ziel
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
|
|
||||||
class FakeDb:
|
class FakeDb:
|
||||||
|
|||||||
@@ -13,14 +13,22 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
hb_schluss,
|
||||||
parse_msg,
|
parse_msg,
|
||||||
|
unbekannter_schalter,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_makemkv_cmd_vollstaendig():
|
def test_makemkv_cmd_vollstaendig():
|
||||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||||
assert cmd[0] == "makemkvcon"
|
# Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter
|
||||||
|
# Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem
|
||||||
|
# nackten Namen faende `subprocess` es dort nie. Im Container bleibt es
|
||||||
|
# der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf
|
||||||
|
# dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig;
|
||||||
|
# gepruefet wird deshalb, WORAUF der Befehl zeigt.
|
||||||
|
assert "makemkvcon" in cmd[0].lower()
|
||||||
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
||||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||||
@@ -34,15 +42,65 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
|
||||||
für Blu-rays prinzipiell funktionsunfähig)."""
|
für Blu-rays prinzipiell funktionsunfähig)."""
|
||||||
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
|
||||||
assert cmd[0] == "HandBrakeCLI"
|
# Wie beim MakeMKV-Befehl: seit V2-4 steht hier der GEFUNDENE Pfad. Unter
|
||||||
|
# Windows liegt HandBrakeCLI in Program Files oder in Rippys eigenem
|
||||||
|
# Werkzeug-Ordner und nicht im PATH — mit dem nackten Namen faende
|
||||||
|
# `subprocess` es dort nie.
|
||||||
|
assert "handbrakecli" in cmd[0].lower()
|
||||||
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
|
||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
assert "--preset" in cmd
|
||||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||||
assert "--audio-codec" in cmd
|
assert "--aencoder" in cmd
|
||||||
assert "--all-subtitles" in cmd
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_kennt_keinen_schalter_audio_codec():
|
||||||
|
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
|
||||||
|
|
||||||
|
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
|
||||||
|
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
|
||||||
|
das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit
|
||||||
|
ins Leere, ohne dass irgendwo ein Grund stand.
|
||||||
|
|
||||||
|
⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
|
||||||
|
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
|
||||||
|
ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten
|
||||||
|
Programm gemessen, nicht aus dem Gedächtnis behauptet.
|
||||||
|
"""
|
||||||
|
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
|
||||||
|
assert "--audio-codec" not in cmd
|
||||||
|
assert cmd[cmd.index("--aencoder") + 1] == "copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_wird_erkannt():
|
||||||
|
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"unknown option (--audio-codec)") == "--audio-codec"
|
||||||
|
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
|
||||||
|
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekannter_schalter("") == ""
|
||||||
|
assert unbekannter_schalter(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
|
||||||
|
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
|
||||||
|
0 ist, sagt er nichts (Befund 30.08.2026)."""
|
||||||
|
assert hb_schluss([]) == ""
|
||||||
|
assert hb_schluss(None) == ""
|
||||||
|
assert hb_schluss([" ", " "]) == ""
|
||||||
|
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
|
||||||
|
assert "fünf" in text and "vier" in text
|
||||||
|
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
|
||||||
|
assert "eins" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
@@ -365,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|||||||
assert ergebnis["return_code"] == 3
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
|
||||||
|
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und
|
||||||
|
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
|
||||||
|
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
|
||||||
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
@@ -380,91 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
|
||||||
|
assert "irgendwas ganz anderes" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
|
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
|
||||||
|
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
|
||||||
|
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
|
||||||
|
|
||||||
|
Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg,
|
||||||
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
|
|
||||||
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
|
|
||||||
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
|
|
||||||
"""
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
|
class FakeProcess:
|
||||||
"aufrufe": []}
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"[13:30:54] hb_init: starting libhb thread\n",
|
||||||
|
"unknown option (--audio-codec)\n",
|
||||||
|
"HandBrake has exited.\n",
|
||||||
|
])
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
def ioctl_fn(fd, befehl, arg=0):
|
def kill(self):
|
||||||
zustand["aufrufe"].append(befehl)
|
pass
|
||||||
if befehl == ripping.CDROM_LOCKDOOR:
|
|
||||||
if not kennt_lockdoor:
|
def wait(self):
|
||||||
raise OSError("ioctl unbekannt")
|
|
||||||
zustand["verriegelt"] = bool(arg)
|
|
||||||
return 0
|
return 0
|
||||||
if befehl == ripping.CDROMEJECT:
|
|
||||||
if not zustand["verriegelt"]:
|
|
||||||
zustand["status"] = ripping.CDS_TRAY_OPEN
|
|
||||||
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
|
|
||||||
if befehl == ripping.CDROM_DRIVE_STATUS:
|
|
||||||
return zustand["status"]
|
|
||||||
raise OSError("unerwartetes ioctl")
|
|
||||||
|
|
||||||
return zustand, ioctl_fn
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
import ripping
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
zustand, ioctl_fn = _laufwerk(verriegelt=True)
|
|
||||||
ok = ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None)
|
|
||||||
assert ok is True
|
|
||||||
assert zustand["status"] == ripping.CDS_TRAY_OPEN
|
|
||||||
# Reihenfolge: entriegeln VOR auswerfen
|
|
||||||
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
|
|
||||||
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
|
|
||||||
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
|
|
||||||
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
|
|
||||||
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
|
|
||||||
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
|
|
||||||
ok = ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None)
|
|
||||||
assert ok is False
|
|
||||||
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
|
|
||||||
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
|
|
||||||
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
|
|
||||||
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_auswurf_ohne_laufwerk_wirft_nicht():
|
|
||||||
import ripping
|
|
||||||
|
|
||||||
def oeffnen_kaputt(p):
|
|
||||||
raise OSError("kein Laufwerk")
|
|
||||||
|
|
||||||
assert ripping.wirf_disc_aus(
|
|
||||||
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
|
||||||
schliessen=lambda fd: None, warten=lambda s: None) is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
@@ -593,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
|
|||||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der
|
||||||
|
# Disk schlug fehl — keine MKV-Datei entstanden"
|
||||||
|
#
|
||||||
|
# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem
|
||||||
|
# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein
|
||||||
|
# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen
|
||||||
|
# Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_wird_vorher_erkannt():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_NO_DISC
|
||||||
|
|
||||||
|
grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC)
|
||||||
|
assert "Kein Datentraeger" in grund
|
||||||
|
assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offene_schublade_auch():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_disc_wird_nicht_gemeckert():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_DISC_OK
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT():
|
||||||
|
"""„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das
|
||||||
|
Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
def wirft(p):
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=wirft) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_makemkv_sieht_vorher_nach():
|
||||||
|
"""Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft
|
||||||
|
man weiterhin zwei Minuten ins Leere."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
quelle = inspect.getsource(ripping.run_makemkv)
|
||||||
|
vor_popen = quelle.index("subprocess.Popen")
|
||||||
|
assert "disc_fehlt(" in quelle[:vor_popen], \
|
||||||
|
"die Disc-Pruefung gehoert vor den makemkvcon-Start"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_sammelmeldung_5010_wird_erklaert():
|
||||||
|
"""5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts."""
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES
|
||||||
|
|
||||||
|
assert 5010 in KRITISCHE_CODES
|
||||||
|
assert "Disc" in KRITISCHE_CODES[5010]
|
||||||
|
|||||||
@@ -221,7 +221,11 @@ def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
|
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
|
||||||
|
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
|
||||||
|
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
|
||||||
|
# gruen, ohne etwas zu beweisen.
|
||||||
|
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ablauf.py")
|
||||||
quelle = open(pfad, encoding="utf-8").read()
|
quelle = open(pfad, encoding="utf-8").read()
|
||||||
|
|
||||||
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
|
|
||||||
|
|
||||||
## Der Befund, der das nötig gemacht hat (26.07.2026)
|
|
||||||
|
|
||||||
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
|
|
||||||
recht — es war kein Geist, sondern der Herzschlag des Workers.
|
|
||||||
|
|
||||||
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
|
|
||||||
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
|
|
||||||
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
|
|
||||||
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
|
|
||||||
kein Fenster.
|
|
||||||
|
|
||||||
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
|
|
||||||
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
|
|
||||||
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
|
|
||||||
Schüben — genau das „alle paar Sekunden".
|
|
||||||
|
|
||||||
## Warum eine eigene Datei
|
|
||||||
|
|
||||||
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
|
|
||||||
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
|
|
||||||
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
|
|
||||||
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
|
|
||||||
derselbe Code auf beiden Seiten läuft.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
|
|
||||||
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
|
|
||||||
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
|
||||||
|
|
||||||
|
## Aufruf
|
||||||
|
|
||||||
|
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
|
||||||
|
|
||||||
|
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
|
||||||
|
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
|
||||||
|
* pyinstaller, pystray, pillow
|
||||||
|
* Das UI muss gebaut sein: cd docker/ui && npm run build
|
||||||
|
|
||||||
|
## Warum die Versions-Ressource nicht optional ist
|
||||||
|
|
||||||
|
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
|
||||||
|
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
|
||||||
|
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
|
||||||
|
und Windows SmartScreen sieht das genauso.
|
||||||
|
|
||||||
|
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
|
||||||
|
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
|
||||||
|
daneben aus dieser Ressource.
|
||||||
|
|
||||||
|
## Was mit hinein muss
|
||||||
|
|
||||||
|
docker/api/ die API-Module (main.py und Nachbarn)
|
||||||
|
docker/ui/dist/ die gebaute Weboberflaeche
|
||||||
|
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
|
||||||
|
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
|
||||||
|
|
||||||
|
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
|
||||||
|
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||||
|
|
||||||
|
VERSION_VORLAGE = """
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=({v0}, {v1}, {v2}, 0),
|
||||||
|
prodvers=({v0}, {v1}, {v2}, 0),
|
||||||
|
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo([
|
||||||
|
StringTable(u'040704b0', [
|
||||||
|
StringStruct(u'CompanyName', u'Rippy'),
|
||||||
|
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
|
||||||
|
StringStruct(u'FileVersion', u'{version}'),
|
||||||
|
StringStruct(u'InternalName', u'Rippy'),
|
||||||
|
StringStruct(u'OriginalFilename', u'Rippy.exe'),
|
||||||
|
StringStruct(u'ProductName', u'Rippy'),
|
||||||
|
StringStruct(u'ProductVersion', u'{version}')])
|
||||||
|
]),
|
||||||
|
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
|
||||||
|
# Windows die Angaben je nach Systemsprache gar nicht an.
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def versionsdatei(version: str, ordner: str) -> str:
|
||||||
|
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
|
||||||
|
pfad = os.path.join(ordner, "version_info.txt")
|
||||||
|
with open(pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write(VERSION_VORLAGE.format(
|
||||||
|
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def pruefen() -> list:
|
||||||
|
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
|
||||||
|
|
||||||
|
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
|
||||||
|
aussieht wie fertig und es nicht ist.
|
||||||
|
"""
|
||||||
|
maengel = []
|
||||||
|
if sys.platform != "win32":
|
||||||
|
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
|
||||||
|
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
|
||||||
|
maengel.append("docker/api/main.py fehlt.")
|
||||||
|
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
|
||||||
|
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
|
||||||
|
"die App nicht rippen.")
|
||||||
|
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
|
||||||
|
maengel.append(
|
||||||
|
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
|
||||||
|
"Abhilfe: cd docker/ui && npm ci && npm run build")
|
||||||
|
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||||
|
if not os.path.isfile(icon):
|
||||||
|
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
|
||||||
|
else:
|
||||||
|
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
|
||||||
|
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
|
||||||
|
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
|
||||||
|
# noch keine Datei, die funktioniert.
|
||||||
|
import sys as _sys
|
||||||
|
_sys.path.insert(0, HIER)
|
||||||
|
from icon import fehlende_pflichtgroessen
|
||||||
|
|
||||||
|
fehlt = fehlende_pflichtgroessen(icon)
|
||||||
|
if fehlt:
|
||||||
|
maengel.append(
|
||||||
|
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
|
||||||
|
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||||
|
% (fehlt,))
|
||||||
|
try:
|
||||||
|
import PyInstaller # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
||||||
|
for modul, paket, wofuer in (
|
||||||
|
("pystray", "pystray", "kein Tray-Symbol"),
|
||||||
|
("PIL", "pillow", "kein Tray-Symbol"),
|
||||||
|
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
|
||||||
|
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
__import__(modul)
|
||||||
|
except ImportError:
|
||||||
|
maengel.append(
|
||||||
|
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
|
||||||
|
return maengel
|
||||||
|
|
||||||
|
|
||||||
|
def bauen(ausgabe: str, version: str) -> str:
|
||||||
|
arbeit = os.path.join(ausgabe, "_bau")
|
||||||
|
os.makedirs(arbeit, exist_ok=True)
|
||||||
|
|
||||||
|
trenner = ";" # Windows-Trennzeichen fuer --add-data
|
||||||
|
|
||||||
|
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
|
||||||
|
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
|
||||||
|
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
|
||||||
|
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
|
||||||
|
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
|
||||||
|
import sys as _sys
|
||||||
|
_sys.path.insert(0, HIER)
|
||||||
|
from werkzeuge import paket_ordner_bauen
|
||||||
|
|
||||||
|
werkzeug_ordner = paket_ordner_bauen(ausgabe)
|
||||||
|
if not werkzeug_ordner:
|
||||||
|
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
|
||||||
|
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
|
||||||
|
|
||||||
|
befehl = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--noconfirm", "--clean",
|
||||||
|
"--onefile",
|
||||||
|
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
|
||||||
|
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
|
||||||
|
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
|
||||||
|
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
|
||||||
|
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
|
||||||
|
# Sekunden auf, bis Python so weit ist.
|
||||||
|
#
|
||||||
|
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
|
||||||
|
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
|
||||||
|
"--windowed",
|
||||||
|
"--name", "RippySetup",
|
||||||
|
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
|
||||||
|
"--version-file", versionsdatei(version, arbeit),
|
||||||
|
"--distpath", ausgabe,
|
||||||
|
"--workpath", os.path.join(arbeit, "work"),
|
||||||
|
"--specpath", arbeit,
|
||||||
|
"--paths", os.path.join(REPO, "src"),
|
||||||
|
"--paths", os.path.join(REPO, "docker", "api"),
|
||||||
|
"--paths", os.path.join(REPO, "docker", "worker"),
|
||||||
|
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
|
||||||
|
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
|
||||||
|
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
|
||||||
|
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
|
||||||
|
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
|
||||||
|
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
|
||||||
|
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
|
||||||
|
"--hidden-import", "uvicorn.logging",
|
||||||
|
"--hidden-import", "uvicorn.loops.auto",
|
||||||
|
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||||
|
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||||
|
"--hidden-import", "uvicorn.lifespan.on",
|
||||||
|
"--hidden-import", "rippy.drives.windows",
|
||||||
|
"--hidden-import", "pystray._win32",
|
||||||
|
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
|
||||||
|
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
|
||||||
|
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
|
||||||
|
# genau dorthin, wovon wir gerade weg wollten.
|
||||||
|
"--hidden-import", "webview",
|
||||||
|
"--hidden-import", "webview.platforms.winforms",
|
||||||
|
"--hidden-import", "webview.platforms.edgechromium",
|
||||||
|
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
||||||
|
# und beides zieht Ballast nach.
|
||||||
|
"--exclude-module", "psycopg2",
|
||||||
|
"--exclude-module", "tkinter",
|
||||||
|
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
|
||||||
|
]
|
||||||
|
if werkzeug_ordner:
|
||||||
|
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
|
||||||
|
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
|
||||||
|
|
||||||
|
print("PyInstaller wird aufgerufen …\n")
|
||||||
|
ergebnis = subprocess.run(befehl, cwd=REPO)
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
|
||||||
|
|
||||||
|
exe = os.path.join(ausgabe, "RippySetup.exe")
|
||||||
|
if not os.path.isfile(exe):
|
||||||
|
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
|
||||||
|
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
|
||||||
|
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
|
||||||
|
p.add_argument("--nur-pruefen", action="store_true")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
print("Rippy — Windows-Bau")
|
||||||
|
print(" Repo %s" % REPO)
|
||||||
|
print(" Ausgabe %s" % args.ausgabe)
|
||||||
|
print(" Version %s" % args.version)
|
||||||
|
print()
|
||||||
|
|
||||||
|
maengel = pruefen()
|
||||||
|
if maengel:
|
||||||
|
print("Der Bau kann so nicht laufen:")
|
||||||
|
for m in maengel:
|
||||||
|
print(" X %s" % m)
|
||||||
|
return 1
|
||||||
|
print("Alle Voraussetzungen erfuellt.")
|
||||||
|
if args.nur_pruefen:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(args.ausgabe, exist_ok=True)
|
||||||
|
exe = bauen(args.ausgabe, args.version)
|
||||||
|
|
||||||
|
groesse = os.path.getsize(exe) / 1024**2
|
||||||
|
print()
|
||||||
|
print("Fertig: %s (%.1f MB)" % (exe, groesse))
|
||||||
|
|
||||||
|
arbeit = os.path.join(args.ausgabe, "_bau")
|
||||||
|
shutil.rmtree(arbeit, ignore_errors=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Einstiegspunkt der gebuendelten RippySetup.exe.
|
||||||
|
|
||||||
|
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
|
||||||
|
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
|
||||||
|
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
|
||||||
|
|
||||||
|
## Warum hier trotzdem etwas zur Ausgabe steht
|
||||||
|
|
||||||
|
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
|
||||||
|
KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026:
|
||||||
|
„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
|
||||||
|
|
||||||
|
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
|
||||||
|
schreibt — uvicorn tut das —, wuerde mit
|
||||||
|
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
|
||||||
|
zwar mitten im Start, ohne dass irgendwo etwas stuende.
|
||||||
|
|
||||||
|
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
|
||||||
|
|
||||||
|
1. In einem Terminal gestartet? -> dort hineinschreiben
|
||||||
|
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
|
||||||
|
"""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _ausgabe_richten() -> None:
|
||||||
|
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
|
||||||
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
|
if not winlauf.ohne_konsole():
|
||||||
|
return # aus dem Quellcode gestartet
|
||||||
|
if winlauf.an_elternkonsole_haengen():
|
||||||
|
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
|
||||||
|
# sys.stdout trotz Konsole None.
|
||||||
|
try:
|
||||||
|
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
|
||||||
|
buffering=1)
|
||||||
|
sys.stderr = sys.stdout
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
winlauf.ausgabe_umleiten()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
|
||||||
|
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
|
||||||
|
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
_ausgabe_richten()
|
||||||
|
|
||||||
|
from rippy.windows_app import main as rippy_main
|
||||||
|
|
||||||
|
return rippy_main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
|
||||||
|
|
||||||
|
## Der Befund (28.08.2026)
|
||||||
|
|
||||||
|
Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
|
||||||
|
Leeres Blatt)."*
|
||||||
|
|
||||||
|
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
|
||||||
|
|
||||||
|
Bilder = 1
|
||||||
|
256x256 32 bit 5657 Bytes PNG
|
||||||
|
|
||||||
|
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
|
||||||
|
die passende Größe aus der Datei:
|
||||||
|
|
||||||
|
Taskleiste, Verknüpfung klein 16x16
|
||||||
|
Desktop, Startmenü 32x32 / 48x48
|
||||||
|
Große Kacheln, Alt-Tab 256x256
|
||||||
|
|
||||||
|
Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt
|
||||||
|
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
|
||||||
|
enthält, passiert das zuverlässig.
|
||||||
|
|
||||||
|
## Was diese Datei tut
|
||||||
|
|
||||||
|
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
|
||||||
|
Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der
|
||||||
|
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||||
|
|
||||||
|
Aufruf (macht der Bau selbst, siehe `build.py`):
|
||||||
|
|
||||||
|
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
|
||||||
|
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
|
||||||
|
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
|
||||||
|
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||||
|
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||||
|
|
||||||
|
|
||||||
|
def groessen_in(pfad: str) -> list:
|
||||||
|
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
|
||||||
|
|
||||||
|
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
|
||||||
|
Fehler, den es hier zu verhindern gilt.
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
with open(pfad, "rb") as f:
|
||||||
|
roh = f.read()
|
||||||
|
if len(roh) < 6:
|
||||||
|
return []
|
||||||
|
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
|
||||||
|
if reserviert != 0 or typ != 1:
|
||||||
|
return []
|
||||||
|
gefunden = []
|
||||||
|
for i in range(anzahl):
|
||||||
|
block = roh[6 + i * 16: 22 + i * 16]
|
||||||
|
if len(block) < 16:
|
||||||
|
break
|
||||||
|
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
|
||||||
|
gefunden.append(breite)
|
||||||
|
return sorted(gefunden)
|
||||||
|
|
||||||
|
|
||||||
|
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
|
||||||
|
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
|
||||||
|
PFLICHTGROESSEN = (16, 32, 48, 256)
|
||||||
|
|
||||||
|
|
||||||
|
def fehlende_pflichtgroessen(pfad: str) -> tuple:
|
||||||
|
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
|
||||||
|
vorhanden = set(groessen_in(pfad))
|
||||||
|
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
|
||||||
|
|
||||||
|
|
||||||
|
def bauen(quelle: str = None, ziel: str = None) -> str:
|
||||||
|
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
|
||||||
|
|
||||||
|
Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt
|
||||||
|
genau zu dem leeren Blatt, das hier abgestellt werden soll.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
quelle = quelle or QUELLE
|
||||||
|
ziel = ziel or quelle
|
||||||
|
|
||||||
|
bild = Image.open(quelle)
|
||||||
|
bild = bild.convert("RGBA")
|
||||||
|
|
||||||
|
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
|
||||||
|
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
|
||||||
|
# Groesse, und aus 16x16 laesst sich kein 256er machen.
|
||||||
|
if hasattr(bild, "size") and bild.size[0] < 256:
|
||||||
|
try:
|
||||||
|
bild = Image.open(quelle)
|
||||||
|
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
|
||||||
|
bild = bild.convert("RGBA")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
ebenen = []
|
||||||
|
for kante in GROESSEN:
|
||||||
|
if kante >= bild.size[0]:
|
||||||
|
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
|
||||||
|
else:
|
||||||
|
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
|
||||||
|
# zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||||
|
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
|
||||||
|
|
||||||
|
ebenen[-1].save(ziel, format="ICO",
|
||||||
|
sizes=[(k, k) for k in GROESSEN])
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
|
||||||
|
p.add_argument("--quelle", default=QUELLE)
|
||||||
|
p.add_argument("--ziel", default=None)
|
||||||
|
p.add_argument("--nur-pruefen", action="store_true")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
vorher = groessen_in(args.quelle)
|
||||||
|
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
|
||||||
|
if args.nur_pruefen:
|
||||||
|
return 0 if len(vorher) > 1 else 1
|
||||||
|
|
||||||
|
ziel = bauen(args.quelle, args.ziel)
|
||||||
|
print("Nachher: %s" % groessen_in(ziel))
|
||||||
|
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
|
||||||
|
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
|
||||||
|
#
|
||||||
|
# py -3.12 -m venv venv
|
||||||
|
# venv\Scripts\pip install -r docker/api/requirements.txt
|
||||||
|
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
|
||||||
|
# venv\Scripts\python packaging/windows/build.py
|
||||||
|
#
|
||||||
|
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
|
||||||
|
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
|
||||||
|
# ist eine Entscheidung, kein Zufall.
|
||||||
|
|
||||||
|
pyinstaller==6.22.2
|
||||||
|
|
||||||
|
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
|
||||||
|
# zu beenden, ausser ueber den Taskmanager.
|
||||||
|
pystray==0.19.5
|
||||||
|
pillow==12.3.0
|
||||||
|
|
||||||
|
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
|
||||||
|
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
|
||||||
|
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
|
||||||
|
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
|
||||||
|
pywebview==6.2.1
|
||||||
|
pythonnet==3.1.0
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
|
||||||
|
|
||||||
|
## Der Befund (28.08.2026)
|
||||||
|
|
||||||
|
Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
|
||||||
|
Leeres Blatt)."*
|
||||||
|
|
||||||
|
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
|
||||||
|
Windows holt sich fuer jede Stelle die passende Groesse:
|
||||||
|
|
||||||
|
Taskleiste, kleine Verknuepfung 16x16
|
||||||
|
Desktop, Startmenue 32x32 / 48x48
|
||||||
|
Alt-Tab, grosse Kacheln 256x256
|
||||||
|
|
||||||
|
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere
|
||||||
|
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
|
||||||
|
regelmaessig.
|
||||||
|
|
||||||
|
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
|
||||||
|
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||||
|
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_modul():
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, HIER)
|
||||||
|
import icon
|
||||||
|
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
|
def _groessen():
|
||||||
|
return _icon_modul().groessen_in(ICON)
|
||||||
|
|
||||||
|
|
||||||
|
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
|
||||||
|
# fordern, die dieser Test nicht prueft — oder umgekehrt.
|
||||||
|
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_ist_da():
|
||||||
|
assert os.path.isfile(ICON), ICON
|
||||||
|
|
||||||
|
|
||||||
|
def test_icon_ist_eine_gueltige_ico():
|
||||||
|
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
|
||||||
|
def test_pflichtgroesse_ist_enthalten(kante):
|
||||||
|
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
|
||||||
|
vorhanden = _groessen()
|
||||||
|
assert kante in vorhanden, (
|
||||||
|
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
|
||||||
|
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||||
|
% (kante, kante, vorhanden)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_einzige_groesse_reicht_nie():
|
||||||
|
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
|
||||||
|
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
|
||||||
|
assert len(_groessen()) > 1, (
|
||||||
|
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
|
||||||
|
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
|
||||||
|
"gefuehrt hat."
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
|
||||||
|
|
||||||
|
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
|
||||||
|
|
||||||
|
Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden
|
||||||
|
oder während des Setups separat installiert werden! Ohne das ist das tool
|
||||||
|
NICHT einsatzfähig."*
|
||||||
|
|
||||||
|
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
|
||||||
|
die Lizenz, nicht die Bequemlichkeit:
|
||||||
|
|
||||||
|
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
|
||||||
|
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
|
||||||
|
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
|
||||||
|
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
|
||||||
|
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
|
||||||
|
startet dessen Installer — der Nutzer bezieht sie also weiterhin von
|
||||||
|
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
|
||||||
|
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
|
||||||
|
|
||||||
|
## Warum die Datei nicht im Repo liegt
|
||||||
|
|
||||||
|
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
|
||||||
|
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
|
||||||
|
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket —
|
||||||
|
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
|
||||||
|
|
||||||
|
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||||
|
|
||||||
|
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
|
||||||
|
# verlangt ihn bei jeder Weitergabe.
|
||||||
|
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
|
||||||
|
|
||||||
|
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
|
||||||
|
steht unter der GPL-2. Das bedeutet fuer dich:
|
||||||
|
|
||||||
|
* Du darfst es weitergeben und veraendern.
|
||||||
|
* Der Quelltext ist oeffentlich verfuegbar unter:
|
||||||
|
https://github.com/HandBrake/HandBrake
|
||||||
|
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
|
||||||
|
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
||||||
|
|
||||||
|
Mitgelieferte Fassung: {version}
|
||||||
|
Bezogen von: {url}
|
||||||
|
|
||||||
|
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
|
||||||
|
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
|
||||||
|
installiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def vendor_ordner(basis: str = None) -> str:
|
||||||
|
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
|
||||||
|
|
||||||
|
|
||||||
|
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
|
||||||
|
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
|
||||||
|
|
||||||
|
Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist
|
||||||
|
leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll
|
||||||
|
möglich bleiben, muss dann aber laut sagen, was fehlt.
|
||||||
|
"""
|
||||||
|
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||||
|
from rippy.tools import beschaffen
|
||||||
|
|
||||||
|
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
|
||||||
|
exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||||
|
|
||||||
|
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
|
||||||
|
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
|
||||||
|
|
||||||
|
melden(" HandBrakeCLI wird geholt …")
|
||||||
|
try:
|
||||||
|
pfad = beschaffen.handbrake_holen(
|
||||||
|
ziel_ordner=ordner,
|
||||||
|
fortschritt=lambda t, anteil=None: melden(" " + t))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
|
||||||
|
return {"pfad": "", "version": "", "geladen": False}
|
||||||
|
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _version_von(exe: str) -> str:
|
||||||
|
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||||
|
from rippy.tools import katalog
|
||||||
|
|
||||||
|
try:
|
||||||
|
return katalog.version_von("handbrake", exe) or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
|
||||||
|
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
|
||||||
|
|
||||||
|
Struktur — genau die, in der `katalog.kandidaten()` sucht:
|
||||||
|
|
||||||
|
tools/handbrake/HandBrakeCLI.exe
|
||||||
|
tools/handbrake/LIZENZ-HandBrake.txt
|
||||||
|
"""
|
||||||
|
hb = handbrake_bereitstellen(ziel_basis, melden)
|
||||||
|
if not hb["pfad"]:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
|
||||||
|
ordner = os.path.join(basis, "handbrake")
|
||||||
|
os.makedirs(ordner, exist_ok=True)
|
||||||
|
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||||
|
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
|
||||||
|
shutil.copy2(hb["pfad"], ziel_exe)
|
||||||
|
|
||||||
|
from rippy.tools import beschaffen
|
||||||
|
|
||||||
|
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
|
||||||
|
encoding="utf-8") as f:
|
||||||
|
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
|
||||||
|
url=beschaffen.HANDBRAKE_API))
|
||||||
|
|
||||||
|
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
|
||||||
|
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
|
||||||
|
return basis
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ordner = paket_ordner_bauen()
|
||||||
|
print("Paket-Ordner:", ordner or "(leer)")
|
||||||
|
raise SystemExit(0 if ordner else 1)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
||||||
|
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
||||||
|
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
||||||
|
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
|
||||||
|
dist-setup*/
|
||||||
|
vendor/
|
||||||
|
.electron-cache/
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Rippy v5 bauen — Anleitung
|
||||||
|
|
||||||
|
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||||
|
> die Messungen dazu liegen in `beweise/`.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Werkzeug | Version | Anmerkung |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||||
|
| npm | 11 | kommt mit Node 24 |
|
||||||
|
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||||
|
|
||||||
|
## Einrichten — zwei Schritte, nicht einer
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||||
|
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||||
|
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||||
|
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||||
|
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||||
|
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||||
|
|
||||||
|
## Die Läufe
|
||||||
|
|
||||||
|
| Befehl | Was er tut |
|
||||||
|
|--------|------------|
|
||||||
|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||||
|
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||||
|
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||||
|
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||||
|
|
||||||
|
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||||
|
fasst die echten Einstellungen nicht an.
|
||||||
|
|
||||||
|
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||||
|
|
||||||
|
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||||
|
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||||
|
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||||
|
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||||
|
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||||
|
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||||
|
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||||
|
|
||||||
|
## Das Setup bauen (W-6)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run paket
|
||||||
|
```
|
||||||
|
|
||||||
|
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
|
||||||
|
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
||||||
|
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||||
|
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||||
|
|
||||||
|
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||||
|
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||||
|
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||||
|
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||||
|
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||||
|
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||||
|
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||||
|
(`npx electron-builder --win --config electron-builder.yml
|
||||||
|
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||||
|
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||||
|
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||||
|
|
||||||
|
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||||
|
liegen nicht im Git, 74 MB):
|
||||||
|
|
||||||
|
```
|
||||||
|
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
|
||||||
|
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
|
||||||
|
Aufruf ohne eine Zeile Ausgabe)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
|
||||||
|
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
|
||||||
|
in `bau/lizenzen/QUELLEN.md`.
|
||||||
|
|
||||||
|
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
|
||||||
|
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
|
||||||
|
ERSETZEN) — `electron-updater` prüft
|
||||||
|
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
|
||||||
|
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
|
||||||
|
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
|
||||||
|
|
||||||
|
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
|
||||||
|
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
|
||||||
|
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
|
||||||
|
erkennen.
|
||||||
|
|
||||||
|
## Festgelegte Versionen und warum
|
||||||
|
|
||||||
|
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||||
|
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||||
|
Release prüfen, ob eine neue Linie ansteht.
|
||||||
|
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||||
|
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||||
|
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||||
|
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||||
|
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||||
|
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||||
|
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||||
|
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||||
|
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||||
|
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Copyright (C) 2000-2009 Josh Coalson
|
||||||
|
Copyright (C) 2011-2025 Xiph.Org Foundation
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
- Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
- Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
- Neither the name of the Xiph.Org Foundation nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
<https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Moe Ghoul>, 1 April 1989
|
||||||
|
Moe Ghoul, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
|
||||||
|
|
||||||
|
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
|
||||||
|
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
|
||||||
|
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
|
||||||
|
holt es beim Einrichten vom Hersteller.
|
||||||
|
|
||||||
|
## HandBrakeCLI
|
||||||
|
|
||||||
|
- Lizenz: GNU General Public License v2 — Volltext in
|
||||||
|
`HANDBRAKE-GPL-2.0.txt` (bezogen von
|
||||||
|
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
|
||||||
|
- Quelltext und Projekt: https://handbrake.fr bzw.
|
||||||
|
https://github.com/HandBrake/HandBrake — der Quelltext der
|
||||||
|
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
|
||||||
|
|
||||||
|
## FLAC (flac.exe + libFLAC.dll)
|
||||||
|
|
||||||
|
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
|
||||||
|
`FLAC-COPYING.Xiph` (bezogen von
|
||||||
|
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
|
||||||
|
30.08.2026).
|
||||||
|
- Projekt: https://xiph.org/flac/
|
||||||
|
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
|
||||||
|
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
|
||||||
|
|
||||||
|
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
|
||||||
|
|
||||||
|
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
|
||||||
|
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
|
||||||
|
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
|
||||||
|
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
|
||||||
|
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
|
||||||
|
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
|
||||||
|
keine Schriften aus dem Netz.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,42 @@
|
|||||||
|
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
|
||||||
|
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
|
||||||
|
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
|
||||||
|
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
|
||||||
|
appId: de.krbrz.rippy
|
||||||
|
productName: Rippy
|
||||||
|
directories:
|
||||||
|
output: dist-setup
|
||||||
|
buildResources: bau
|
||||||
|
files:
|
||||||
|
- out/**
|
||||||
|
- package.json
|
||||||
|
extraResources:
|
||||||
|
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
|
||||||
|
- from: vendor
|
||||||
|
to: vendor
|
||||||
|
- from: bau/lizenzen
|
||||||
|
to: lizenzen
|
||||||
|
- from: bau/tray.png
|
||||||
|
to: bau/tray.png
|
||||||
|
win:
|
||||||
|
icon: bau/icon.ico
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
nsis:
|
||||||
|
oneClick: true
|
||||||
|
perMachine: false
|
||||||
|
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
|
||||||
|
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
|
||||||
|
allowToChangeInstallationDirectory: false
|
||||||
|
deleteAppDataOnUninstall: false
|
||||||
|
artifactName: RippySetup-${version}.${ext}
|
||||||
|
publish:
|
||||||
|
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
|
||||||
|
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
|
||||||
|
# ersten echten Release gemessen — § 3.6 ⚠).
|
||||||
|
provider: generic
|
||||||
|
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
|
||||||
|
electronDownload:
|
||||||
|
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
|
||||||
|
# lädt die Binärdateien selbst über diesen Cache-Weg.
|
||||||
|
cache: .electron-cache
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||||
|
// haupt → out/haupt/index.js (Electron Main)
|
||||||
|
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||||
|
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||||
|
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||||
|
// fenster → out/fenster/ (React)
|
||||||
|
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||||
|
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||||
|
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||||
|
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||||
|
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||||
|
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||||
|
function cspNurImBau(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'csp-nur-im-bau',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<head>',
|
||||||
|
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||||
|
// bleibt alles bei 'self'.
|
||||||
|
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/haupt',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||||
|
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/bruecke',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: 'src/fenster',
|
||||||
|
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/fenster',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "rippy",
|
||||||
|
"productName": "Rippy",
|
||||||
|
"version": "5.1.0",
|
||||||
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
|
"main": "out/haupt/index.js",
|
||||||
|
"author": "KrBrZ",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"smoke": "electron-vite build && electron . --smoke",
|
||||||
|
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||||
|
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
|
"koffi": "^3.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@fontsource/instrument-sans": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"electron": "44.0.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "^7.3.6",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
|
||||||
|
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
|
||||||
|
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
|
||||||
|
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
|
||||||
|
// Kontextgrenze — contextBridge kann keine Ports übertragen)
|
||||||
|
// 2. den Haupt-Status (Kanal 'haupt-status')
|
||||||
|
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
|
||||||
|
//
|
||||||
|
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
|
||||||
|
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
|
||||||
|
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
|
||||||
|
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
|
||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
|
||||||
|
let seiteBereit = false
|
||||||
|
const wartendePorts: IpcRendererEvent[] = []
|
||||||
|
|
||||||
|
ipcRenderer.on('kern-port', (ereignis) => {
|
||||||
|
if (seiteBereit) {
|
||||||
|
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||||
|
} else {
|
||||||
|
wartendePorts.push(ereignis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let letzterStatus: unknown = null
|
||||||
|
const statusHorcher = new Set<(status: unknown) => void>()
|
||||||
|
|
||||||
|
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
|
||||||
|
letzterStatus = status
|
||||||
|
for (const horcher of statusHorcher) horcher(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('rippy', {
|
||||||
|
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
|
||||||
|
bereit(): void {
|
||||||
|
seiteBereit = true
|
||||||
|
for (const ereignis of wartendePorts.splice(0)) {
|
||||||
|
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
|
||||||
|
aufStatus(horcher: (status: unknown) => void): void {
|
||||||
|
statusHorcher.add(horcher)
|
||||||
|
if (letzterStatus !== null) horcher(letzterStatus)
|
||||||
|
},
|
||||||
|
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
|
||||||
|
melden(art: string): void {
|
||||||
|
ipcRenderer.send('fenster-meldung', String(art))
|
||||||
|
},
|
||||||
|
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
|
||||||
|
ordnerWaehlen(titel: string): Promise<string | null> {
|
||||||
|
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
|
||||||
|
},
|
||||||
|
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
|
||||||
|
autostartSetzen(an: boolean): Promise<boolean> {
|
||||||
|
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
rippy: {
|
||||||
|
bereit(): void
|
||||||
|
aufStatus(horcher: (status: unknown) => void): void
|
||||||
|
melden(art: string): void
|
||||||
|
ordnerWaehlen(titel: string): Promise<string | null>
|
||||||
|
autostartSetzen(an: boolean): Promise<boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
|
||||||
|
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
|
||||||
|
import '@fontsource-variable/bricolage-grotesque'
|
||||||
|
import '@fontsource/instrument-sans/400.css'
|
||||||
|
import '@fontsource/instrument-sans/500.css'
|
||||||
|
import '@fontsource/instrument-sans/600.css'
|
||||||
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
|
import '@fontsource/jetbrains-mono/500.css'
|
||||||
|
import '@fontsource/jetbrains-mono/700.css'
|
||||||
|
import './stil.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('wurzel')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rippy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wurzel"></div>
|
||||||
|
<script type="module" src="./haupt.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||