Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84c5ee399 | ||
|
|
fbedb9946d | ||
|
|
eb54c59141 | ||
|
|
0088948b4f | ||
|
|
d8d34e8fbb | ||
|
|
5353b381ba | ||
|
|
9d16e82b5f | ||
|
|
d45760018e | ||
|
|
e7e33e9021 | ||
|
|
fc2d16ca38 | ||
|
|
0fc87aa022 | ||
|
|
6b6e5c3436 | ||
|
|
ae82d8c0fd | ||
|
|
3dacd7cb4f | ||
|
|
c1ee339446 | ||
|
|
783b8e4897 | ||
|
|
551f1d7b2c | ||
|
|
904e4b2ef6 | ||
|
|
8c4456d005 | ||
|
|
12b9720386 | ||
|
|
6f1add480e | ||
|
|
473eea7d4e | ||
|
|
687173430c | ||
|
|
8f671ff160 | ||
|
|
512b38cced | ||
|
|
f5dab5d1c1 | ||
|
|
76ed3b337a | ||
|
|
2ae79fceb4 | ||
|
|
3dc6798c79 | ||
|
|
6678c81592 | ||
|
|
2c74dc6958 | ||
|
|
6555562a4c | ||
|
|
c46a280b1f | ||
|
|
6dd1b0bc7a | ||
|
|
e3ce8220f1 | ||
|
|
3f7f962dff | ||
|
|
d70a81d194 | ||
|
|
7c489f8404 | ||
|
|
1f678c036d | ||
|
|
6d149212c9 | ||
|
|
9622f4e6ef | ||
|
|
306b090ccf | ||
|
|
7ce06889f8 | ||
|
|
043052b453 | ||
|
|
a8e8b2bef3 | ||
|
|
1253148652 | ||
|
|
2549b9643b | ||
|
|
663127f854 | ||
|
|
45402e887e | ||
|
|
7a4f92036d | ||
|
|
051cab3afa | ||
|
|
1d53e8526f | ||
|
|
827910fd63 | ||
|
|
0c38754c09 | ||
|
|
c0a7618e33 | ||
|
|
7d496cf654 | ||
|
|
4f943e2c4f | ||
|
|
38a3a87f70 | ||
|
|
673f7ff3e7 | ||
|
|
5522a50425 | ||
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b | ||
|
|
6881a63fb7 | ||
|
|
35370fd552 | ||
|
|
11001a443e | ||
|
|
be3fac53af | ||
|
|
878b24c43b | ||
|
|
ea8a043643 | ||
|
|
95beb91364 | ||
|
|
480b658294 | ||
|
|
16df61ea3a | ||
|
|
3d5f82b786 | ||
|
|
8e65411189 | ||
|
|
082e8b8b55 | ||
|
|
6dfff5c99d |
@@ -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")
|
||||||
|
|||||||
@@ -55,3 +55,6 @@ logs/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
dist/
|
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.
|
||||||
|
|||||||
@@ -1415,6 +1415,56 @@ 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
|
Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem
|
||||||
Netz; sie muss auf einem Rechner ohne Internet aufgehen.
|
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.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||||
|
|
||||||
|
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||||
|
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||||
|
Konzept hängt:
|
||||||
|
|
||||||
|
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||||
|
|
||||||
|
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||||
|
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||||
|
|
||||||
|
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install koffi
|
||||||
|
node laufwerk.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||||
|
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||||
|
|
||||||
|
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||||
|
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||||
|
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||||
|
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||||
|
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||||
|
| Rev: 1.03 | Serial: 0025114C0149
|
||||||
|
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||||
|
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||||
|
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||||
|
```
|
||||||
|
|
||||||
|
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||||
|
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||||
|
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||||
|
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||||
|
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||||
|
|
||||||
|
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||||
|
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||||
|
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||||
|
ein antwortendes Laufwerk für tot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `leine.js` — die Prozess-Leine (Job Object)
|
||||||
|
|
||||||
|
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||||
|
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||||
|
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||||
|
|
||||||
|
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||||
|
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||||
|
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||||
|
Drüber-Installieren passiert.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||||
|
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||||
|
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||||
|
4. Kindprozess gestartet, PID 16040
|
||||||
|
|
||||||
|
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||||
|
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||||
|
danach: Eltern lebt: False Kind lebt: False
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachstellen (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||||
|
Start-Sleep 3
|
||||||
|
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||||
|
taskkill /F /PID $p.Id
|
||||||
|
Start-Sleep 2
|
||||||
|
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||||
|
|
||||||
|
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||||
|
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||||
|
überhaupt vorhanden?
|
||||||
|
|
||||||
|
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||||
|
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||||
|
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||||
|
Kern beendet mit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||||
|
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||||
|
Messung zuvor ebenfalls grün.)
|
||||||
|
|
||||||
|
Nachstellen:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install electron@44 --no-save
|
||||||
|
node node_modules/electron/install.js
|
||||||
|
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||||
|
|
||||||
|
```
|
||||||
|
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||||
|
```
|
||||||
|
|
||||||
|
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||||
|
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||||
|
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||||
|
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||||
|
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||||
|
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||||
|
Tages jemand an der falschen Stelle.
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Durchsicht Rippy v5 (5.6.1) — Bericht vom 12.09.2026
|
||||||
|
|
||||||
|
> Anlass: Drei Filme (Der Patriot, Déjà Vu, Training Day) lassen sich auf
|
||||||
|
> einem ANDEREN PC mit Rippy nicht bearbeiten, während MakeMKV allein sie
|
||||||
|
> lesen und öffnen kann. Dazu die Bitte um einen kompletten Check von
|
||||||
|
> Kern, Haupt und Fenster.
|
||||||
|
>
|
||||||
|
> Was hier steht, ist GEMESSEN oder im Quelltext mit Zeile belegt — nichts
|
||||||
|
> ist geraten. Wo ich etwas nicht messen konnte, steht das dabei.
|
||||||
|
|
||||||
|
Stand des Repos: Branch `worktree-windows-electron`, Spitze `473eea7`,
|
||||||
|
Arbeitsbaum sauber. `npm run typecheck` grün, `npm test` grün
|
||||||
|
(450 Tests bestanden, 12 übersprungen — die Messungen, die ein Laufwerk
|
||||||
|
brauchen). Gelesen wurden ALLE 92 Quelldateien unter `rippy-windows/src`
|
||||||
|
(16 081 Zeilen), die Tests, die Bau-Konfiguration und die Protokolle des
|
||||||
|
echten Rippy auf diesem PC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die drei Filme — was am anderen PC dahintersteckt
|
||||||
|
|
||||||
|
Ich habe die Disc-Ausgaben des anderen PCs nicht; ich habe aber ALLE Wege
|
||||||
|
gefunden, auf denen Rippy scheitert, während MakeMKV allein weiterkommt.
|
||||||
|
Sie stehen in der Reihenfolge, in der ich sie für wahrscheinlich halte —
|
||||||
|
mit der Zeile, an der man sie am anderen PC in einer Minute erkennt.
|
||||||
|
|
||||||
|
**Die Protokoll-Datei liegt dort unter `%APPDATA%\Rippy\logs\rippy-JJJJ-MM-TT.log`**
|
||||||
|
(im Programm: Einstellungen → Allgemein → „Protokoll öffnen"). Die Zeilen
|
||||||
|
des Tages, an dem einer der drei Filme versucht wurde, entscheiden.
|
||||||
|
|
||||||
|
### Kandidat 1 — MakeMKV läuft dort ohne gültigen Schlüssel (Beta-Key-Fehler in Rippy)
|
||||||
|
|
||||||
|
Der frei veröffentlichte Beta-Key vom September 2026 enthält ein `@`:
|
||||||
|
`T-vwvn…6Ox4BI@N5rPp` (68 Zeichen, heute von der Herstellerseite geladen,
|
||||||
|
„valid until end of September 2026"). Rippys Muster
|
||||||
|
`KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/` (`kern/werkzeuge/schluessel.ts:19`)
|
||||||
|
kennt kein `@` und schneidet den Schlüssel auf 62 Zeichen ab (Sonde 9,
|
||||||
|
gemessen an der echten Seite). Den verstümmelten Schlüssel lehnt MakeMKV
|
||||||
|
ab (5020), Rippy nimmt ihn „damit MakeMKV im Beta-Modus weiterläuft" wieder
|
||||||
|
zurück (`schluessel.ts:214-219`) — und MakeMKV läuft ohne Schlüssel.
|
||||||
|
Für DVDs ist das egal (laut Hersteller dauerhaft frei), **für Blu-rays
|
||||||
|
schaltet MakeMKV nach Ablauf des Testzeitraums ab** — nach der
|
||||||
|
Gnadenfrist („noch einige Tage", MSG 5051) beim Speichern, je nach
|
||||||
|
Fassung auch beim Öffnen. Ob das GUI am anderen PC noch in der
|
||||||
|
Gnadenfrist ist, sagt dessen Startmeldung; Rippy dagegen meldet den
|
||||||
|
Zustand nicht als Ausfall (siehe F1/F5).
|
||||||
|
|
||||||
|
Warum es bei DIR läuft: In der echten Datenbank dieses PCs steht unter
|
||||||
|
`makemkvAppKey` ein 68-Zeichen-Schlüssel MIT `@` — du hast den Key von
|
||||||
|
Hand als „Eigener Schlüssel" eingetragen. Der andere PC verlässt sich
|
||||||
|
vermutlich auf die Automatik, und die liefert den kaputten Key.
|
||||||
|
|
||||||
|
*Erkennen:* Einstellungen → MakeMKV zeigt dort gelb „BETA-MODUS — RIPS
|
||||||
|
LAUFEN" oder rot; im Protokoll steht `Schlüssel: Beta-Key abgelehnt … —
|
||||||
|
wieder entfernt`; ein gescheiterter Rip trägt `Ursache: MakeMKV ist nicht
|
||||||
|
freigeschaltet (Testzeitraum abgelaufen)`; MakeMKV-GUI zeigt beim Start
|
||||||
|
„Der Testzeitraum ist abgelaufen".
|
||||||
|
*Sofort-Abhilfe ohne neue Version:* den vollständigen Key (mit `@`) von
|
||||||
|
Hand unter Einstellungen → MakeMKV → „Eigener Schlüssel" eintragen,
|
||||||
|
„Schlüssel neu prüfen". Gilt bis 30.09.2026 — danach braucht es den
|
||||||
|
Rippy-Fix (Fund 1 unten) oder wieder Handarbeit.
|
||||||
|
|
||||||
|
### Kandidat 2 — Titel lesen bricht nach fünf Minuten ab (der Timeout, nicht die Disc)
|
||||||
|
|
||||||
|
`erkennung.titelLesen` tötet `makemkvcon info` nach 300 s
|
||||||
|
(`kern/dienste/erkennung.ts:315`, `kern/rip/makemkv.ts:170-173`) und
|
||||||
|
meldet „MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer
|
||||||
|
beschädigten Disc kann das passieren". Ältere DVDs mit Kopierschutz
|
||||||
|
(Sony-ARccOS, Disney-99-Titel-Struktur) und Discs mit vielen Playlisten
|
||||||
|
brauchen im MakeMKV-GUI regelmäßig länger als fünf Minuten zum Öffnen —
|
||||||
|
das GUI wartet, Rippy nicht. Ein hart beendetes makemkvcon hinterlässt
|
||||||
|
außerdem gern den 5042-Zustand („findet kein Laufwerk"), sodass der
|
||||||
|
NÄCHSTE Versuch sofort scheitert.
|
||||||
|
*Erkennen:* `Titel-Lauf X gescheitert: MakeMKV hat die Titel nach fünf
|
||||||
|
Minuten nicht gelesen`; im GUI dauert „Disc wird geöffnet" länger als 5 min.
|
||||||
|
|
||||||
|
### Kandidat 3 — der Rip startet still nicht (Ablage oder Arbeitsordner nicht erreichbar)
|
||||||
|
|
||||||
|
`Pipeline.starten` legt den Roh-Ordner mit `mkdirSync` an
|
||||||
|
(`kern/ablauf/pipeline.ts:258`) — ohne Fangnetz. Ist die Ablage oder der
|
||||||
|
Arbeitsordner gerade nicht da (NAS aus, Netzlaufwerk noch nicht
|
||||||
|
verbunden, Buchstabe weg), fliegt eine Ausnahme aus `starten()`, der
|
||||||
|
Aufruf `void laufwerke.bereit.then(() => ripVerwaltung.starten(…))`
|
||||||
|
(`kern/index.ts:198`) verschluckt sie: **keine Meldung im Fenster, keine
|
||||||
|
Zeile im Protokoll, der Dialog schließt sich, nichts passiert** (Sonde 10:
|
||||||
|
`ENOENT … mkdir 'Q:\rippy-nicht-da\roh\…'`, gemeldet: nichts). Der Kern
|
||||||
|
stirbt dabei nicht (in Electron 44 gemessen: ein utilityProcess überlebt
|
||||||
|
eine unbehandelte Promise-Ablehnung) — er schweigt nur.
|
||||||
|
*Erkennen:* Nach `Titel-Lauf X: … s, N Titel` folgt KEINE Zeile
|
||||||
|
`Vorgang … beginnt`; die Kachel bleibt ohne Balken.
|
||||||
|
|
||||||
|
### Kandidat 4 — Disc-Typ „unbekannt" → Rippy sichert ein ISO statt zu rippen
|
||||||
|
|
||||||
|
Der Typ kommt aus `IOCTL_CDROM_DISK_TYPE` + Größe (`kern/laufwerk/disc.ts:52-59`).
|
||||||
|
Antwortet das Laufwerk darauf nicht (bei manchen USB-Brücken
|
||||||
|
`ERROR_INVALID_FUNCTION`) oder ohne Bits, steht `unknown` — die Kachel
|
||||||
|
zeigt „unbekannt" und PRESET „ISO-Abbild", der Knopf „Rippen" öffnet
|
||||||
|
trotzdem den Titel-Dialog (`LaufwerkKachel.tsx:157-166`), und nach der
|
||||||
|
Auswahl legt die Pipeline ein 1:1-Abbild an statt der gewählten Titel
|
||||||
|
(`pipeline.ts:236-241`).
|
||||||
|
*Erkennen:* `Disc eingelegt in X: unknown, … Bytes`, Kachel MEDIUM
|
||||||
|
„unbekannt", Statuszeile „Sichere 1:1-Abbild …".
|
||||||
|
|
||||||
|
### Kandidat 5 — Erkennung bleibt bei 60 %, die Automatik wartet (kein Fehler, aber „tut nichts")
|
||||||
|
|
||||||
|
Alle drei Discs tragen vermutlich englische Labels (`THE_PATRIOT`,
|
||||||
|
`DEJA_VU`, `TRAINING_DAY`). TMDb antwortet auf Deutsch („Der Patriot",
|
||||||
|
„Déjà Vu – Wettlauf gegen die Zeit"); der wörtliche Vergleich
|
||||||
|
`gleichBedeutend` (`kern/metadaten/zuordnung.ts:184-195`) vergleicht nur
|
||||||
|
den deutschen `title`, nie `original_title`, und kennt keine Akzente
|
||||||
|
(Sonde 1: „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot"). Ergebnis:
|
||||||
|
Vorschlag mit 60 % statt 95 % — die Automatik startet nicht
|
||||||
|
(`automatik.ts:79-82`), im Kasten steht „Automatik wartet: Die Erkennung
|
||||||
|
ist nicht sicher genug". Wer auf Zero-Click wartet, sieht: nichts.
|
||||||
|
*Erkennen:* Kachel „Vorschlag" (gelb) statt „sehr wahrscheinlich"; die
|
||||||
|
Automatik-Zeile.
|
||||||
|
|
||||||
|
### Was ich vom anderen PC brauche, um es festzunageln
|
||||||
|
|
||||||
|
1. Die Protokoll-Zeilen des Tages (siehe oben) — am besten die ganze Datei.
|
||||||
|
2. Einstellungen → MakeMKV: der Text der Schlüssel-Karte, und MakeMKV-Version.
|
||||||
|
3. Ob die drei Discs DVD, Blu-ray oder 4K sind.
|
||||||
|
4. Was genau „nicht bearbeiten" war: kein Start, roter Text, ISO, oder ewig „liest die Titel".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fehler — nach Schwere, mit Beleg
|
||||||
|
|
||||||
|
Schwere: **HOCH** = Datenverlust, Ausfall oder stilles Scheitern ·
|
||||||
|
**MITTEL** = falsches Verhalten, das der Nutzer bemerkt · **NIEDRIG** =
|
||||||
|
Schönheit, Bedienung, Nebensache. Jede Nummer verweist auf Datei:Zeile im
|
||||||
|
Stand `473eea7`.
|
||||||
|
|
||||||
|
### HOCH
|
||||||
|
|
||||||
|
**F1 · Beta-Key mit `@` wird abgeschnitten → MakeMKV unlizenziert**
|
||||||
|
`kern/werkzeuge/schluessel.ts:19` (`KEY_MUSTER`), `:24-27`, `:212-219`.
|
||||||
|
Beleg: Sonde 9 an der heute geladenen Forum-Seite: Seite 68 Zeichen mit
|
||||||
|
`@`, Rippy zieht 62. Wirkung: siehe Kandidat 1 — auf jedem PC ohne
|
||||||
|
Hand-Key laufen Blu-ray-Rips nach dem Testzeitraum nicht mehr, und Rippy
|
||||||
|
zeigt dazu GELB „BETA-MODUS — RIPS LAUFEN" (`Karten.tsx:409-413`,
|
||||||
|
`App.tsx:115`). Dazu: `extractGueltigBis` (`:32-37`) erkennt „end of
|
||||||
|
September 2026" nicht → kein Ablaufdatum im Fenster (gemessen: leer).
|
||||||
|
Fix: Muster auf alles bis Leerzeichen/`<` (z. B. `/T-[^\s<"']{50,}/`),
|
||||||
|
Ablehnung ROT mit Klartext „Blu-ray-Rips laufen nicht", und ein Test mit
|
||||||
|
dem echten September-Schlüssel.
|
||||||
|
|
||||||
|
**F2 · Rip-Start ohne erreichbare Ablage/Arbeitsordner scheitert STILL**
|
||||||
|
`kern/ablauf/pipeline.ts:258` (`mkdirSync` ohne try), ebenso `:646`
|
||||||
|
(Audio-CD) und `:721` (ISO); Aufrufer `kern/index.ts:198` und `:236`.
|
||||||
|
Beleg: Sonde 10 + Electron-Messung (utilityProcess lebt weiter, Fehler
|
||||||
|
unsichtbar). Verstößt gegen R4. Fix: `starten()` in try/catch, Fehler als
|
||||||
|
`rip-status fehler` melden UND ins Protokoll; zusätzlich vor dem Start
|
||||||
|
eine Schreibprobe am Roh-Ordner, damit die Kachel sagt „Ablage nicht
|
||||||
|
erreichbar" statt zu schweigen; im Kern ein `process.on('unhandledRejection')`,
|
||||||
|
das ins Protokoll schreibt.
|
||||||
|
|
||||||
|
**F3 · Serien: zweite Disc derselben Staffel — Folgen werden nicht benannt, Überschreiben möglich**
|
||||||
|
`kern/ablage/struktur.ts:224-246`: `episodenUmbenennen` zählt ALLE `.mkv`
|
||||||
|
im Season-Ordner; liegen dort schon `S01E01`/`S01E02` der ersten Disc,
|
||||||
|
heißt es „Umbenennen übersprungen: 4 Dateien, 2 Zuordnungen" (Sonde 2,
|
||||||
|
real ausgeführt). Der Serien-Merker (5.5.0) und „ab Folge" laufen damit ab
|
||||||
|
Disc 2 ins Leere. Schlimmer: Folgen behalten bis dahin MakeMKVs Namen
|
||||||
|
(`ablegen.ts:198-209`, `dateinamen.ts:121`), und `title_t00.mkv` der
|
||||||
|
zweiten Disc landet im SELBEN Ordner (`ablegen.ts:152-156`) — HandBrake
|
||||||
|
bzw. `renameSync` (`ablegen.ts:95-104`) überschreiben eine gleichnamige
|
||||||
|
Datei der ersten Disc ohne Warnung, wenn deren Umbenennung ausgefallen
|
||||||
|
war (kein TMDb). Der bestehende Test `test/ablage.test.ts:106-116`
|
||||||
|
schreibt das Verhalten sogar fest. Fix: nur die NEUEN Dateien dieser
|
||||||
|
Disc umbenennen (Liste übergeben statt Ordner lesen); Zielnamen vor dem
|
||||||
|
Encode auf Kollision prüfen und nie überschreiben.
|
||||||
|
|
||||||
|
**F4 · Kern-Neustart: das Fenster bleibt taub**
|
||||||
|
`haupt/kernstart.ts:59-74` startet den Kern bis zu 3× je Minute neu, aber
|
||||||
|
`fensterVerbinden` läuft nur bei `did-finish-load` (`haupt/index.ts:245-253`);
|
||||||
|
das Fenster hält den toten Port (`kernverbindung.ts:268-282`), `anKern`
|
||||||
|
schickt ins Leere, und `App.tsx:92` zeigt keinen Fehler, weil
|
||||||
|
`verbindung.kern` noch die alte Bereit-Meldung ist. Wirkung: Laufwerke
|
||||||
|
frieren ein, Knöpfe tun nichts — bis Rippy komplett neu gestartet wird.
|
||||||
|
Das KONZEPT verspricht „Der Kern ist abgestürzt, ich starte ihn neu".
|
||||||
|
Fix: im `spawn`-Ereignis des neuen Kerns `fensterVerbinden` erneut
|
||||||
|
aufrufen und eine Zeile im Fenster zeigen.
|
||||||
|
|
||||||
|
**F31 · Nachtrag (12.09. abends, beim Bau von 5.7.0 gemessen): jede
|
||||||
|
Tonspur wurde seit 5.0 in AAC umgerechnet — nicht verlustfrei durchgereicht**
|
||||||
|
`kern/komprimieren/handbrake.ts` gibt `--aencoder copy --audio-fallback
|
||||||
|
av_aac` mit, aber KEIN `--audio-copy-mask`. HandBrakeCLI 1.11.2 verengt
|
||||||
|
die Kopiermaske dann auf „copy:aac" — obwohl die Presets („H.265 MKV
|
||||||
|
1080p30", „H.265 VCN 2160p 4K" …) alle Codecs erlauben. Beleg: Encode-
|
||||||
|
Protokoll `Auto Passthru: allowed codecs are AAC … passthru not possible
|
||||||
|
for track 1, using fallback`; Ausgabe per ffprobe `aac`. Mit
|
||||||
|
ausdrücklicher Maske (`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac`) bleibt
|
||||||
|
AC3 AC3 und die Passthru-Zeile nennt alle acht Codecs (Messung in
|
||||||
|
`test/messung.spuren.test.ts`, `RIPPY_MESSUNG=1`). Wirkung: Jede seit
|
||||||
|
5.0 komprimierte Datei trägt AAC statt DTS-HD MA/TrueHD/AC3 — wo die
|
||||||
|
Roh-Dateien nach der Kompression gelöscht wurden, ist der Original-Ton
|
||||||
|
weg. Fix in 5.7.0: Maske auf beiden Wegen (Sprachlisten und Spuren
|
||||||
|
einzeln). Filme, bei denen der HD-Ton wichtig ist, noch einmal rippen.
|
||||||
|
|
||||||
|
### MITTEL
|
||||||
|
|
||||||
|
**F5 · Schlüssel-Prüfung urteilt ohne Grundlage**
|
||||||
|
`schluessel.ts:108-131`: `info disc:9999` liefert ohne angeschlossenes
|
||||||
|
Laufwerk NUR 5042/5010 (heute gemessen) — keine Lizenzzeile, also
|
||||||
|
`ok: true` und „angenommen", egal was im Register steht. `:179`:
|
||||||
|
das Ergebnis von `keyAblegen` (reg add) wird ignoriert — „hinterlegt"
|
||||||
|
steht auch dann im Protokoll, wenn nichts geschrieben wurde. `:194-237`:
|
||||||
|
Ablehnung liefert `weiterBetrieb: true` (gelb) statt rot.
|
||||||
|
|
||||||
|
**F6 · Jeder Auswurf legt eine weitere Takt-Kette der Disc-Wache an**
|
||||||
|
`kern/laufwerk/wache.ts:285-303`: `runde()` plant IMMER einen neuen
|
||||||
|
Timer, ohne den laufenden zu löschen; `laufwerke.ts:175` ruft `runde()`
|
||||||
|
nach jedem Auswurf. Beleg: Sonde 3 — drei Ketten, neunmal statt fünfmal
|
||||||
|
gelesen. Wirkung: nach 20 Discs fragt Rippy die Laufwerke alle 150 ms
|
||||||
|
statt alle 3 s (CreateFileW + IOCTL je Runde). Fix: vorhandenen Timer
|
||||||
|
in `runde()` erst löschen.
|
||||||
|
|
||||||
|
**F7 · Während der Kompression wird jede Sekunde die ganze Welt neu vermessen**
|
||||||
|
Kette: HandBrake-Fortschritt (mit ETA ≈ 1×/s, `handbrake.ts:152-158`) →
|
||||||
|
`warteschlange.ts:141-146` → `vorgaenge.ts:118-124` `senden()` →
|
||||||
|
`stand()` (`:217-260`) läuft mit `ordnerGroesse` rekursiv durch JEDEN
|
||||||
|
Roh-Ordner plus `fremdeOrdner`, danach `speicher.senden()` mit zwei
|
||||||
|
`statfsSync` (`speicher.ts:237-247`) — alles synchron im Kern; dann geht
|
||||||
|
die volle Vorgangsliste an Haupt und Fenster, das komplett neu zeichnet
|
||||||
|
(`kernverbindung.ts:123-125`). Auf einer NAS als Arbeitsordner ist das
|
||||||
|
ein Netz-Ordnerlauf pro Sekunde. Fix: Fortschritt als kleine Nachricht
|
||||||
|
getrennt vom Vorgangs-Stand; Roh-Größen nur bei Phasenwechsel oder
|
||||||
|
gedrosselt (z. B. alle 30 s) messen.
|
||||||
|
|
||||||
|
**F8 · Das Rettungs-Abbild wird gelöscht, wenn der Rip daraus leer ausgeht**
|
||||||
|
`pipeline.ts:279-300`: `beenden()` entfernt den ganzen Roh-Ordner, wenn
|
||||||
|
keine MKV entstand — inklusive `rettung.iso`, für das die Disc stundenlang
|
||||||
|
Sektor für Sektor gelesen wurde (`halbeDateienEntfernen` schont es,
|
||||||
|
`beenden` nicht). Fix: `rettung.iso` + Bericht vor dem `rmSync` verschonen.
|
||||||
|
|
||||||
|
**F9 · Halbe HandBrake-Ausgabe bleibt nach Abbruch/Fehler liegen**
|
||||||
|
`handbrake.ts:282-285,300-308`, `ablegen.ts:279-286`: nach `kill()` oder
|
||||||
|
Fehler wird die angefangene Datei nicht entfernt. Ohne Arbeitsordner
|
||||||
|
liegt sie direkt in `Filme\<Titel>\` — Jellyfin zeigt einen kaputten
|
||||||
|
Film. Fix: nach `.part`/Zwischenname encodieren, erst am Ende umbenennen;
|
||||||
|
bei Abbruch löschen.
|
||||||
|
|
||||||
|
**F10 · Typ „unbekannt" heißt stumm ISO, obwohl der Nutzer Titel gewählt hat**
|
||||||
|
`pipeline.ts:236-241` gegen `LaufwerkKachel.tsx:157-166`/`Uebersicht.tsx:114-120`
|
||||||
|
(siehe Kandidat 4). Dazu `disc.ts:49`: UHD wird allein an ≥ 55 GiB
|
||||||
|
erkannt — eine 4K-Disc mit 48 GB Daten wird „bluray" (Sonde 4) und
|
||||||
|
bekommt das Blu-ray-Preset. Fix: liegt ein Titel-Auftrag vor, immer
|
||||||
|
MakeMKV; UHD-Merkmal am Gerät nachmessen (Regel D) statt Größe.
|
||||||
|
|
||||||
|
**F11 · Laufwerks-Gesundheit beschuldigt das falsche Gerät**
|
||||||
|
`kern/laufwerk/ereignisse.ts:41-44`: der XPath nimmt Provider `cdrom` UND
|
||||||
|
`disk` für ALLE Geräte; `:67-76` filtert nicht nach `\Device\CdRomN`.
|
||||||
|
Eine hustende Festplatte oder ein zweites Laufwerk erzeugt „VERDACHT:
|
||||||
|
DIE HARDWARE" für die Kachel des Blu-ray-Laufwerks. Fix: Gerät des
|
||||||
|
Laufwerksbuchstabens ermitteln und nur dessen Ereignisse zählen.
|
||||||
|
|
||||||
|
**F12 · Zeiten stehen in UTC, nicht in deiner Uhrzeit**
|
||||||
|
`bausteine.tsx:35-38` (`datumKurz`), `Karten.tsx:269,276,418,574`,
|
||||||
|
`VerlaufKarte.tsx:295`: ISO-Strings werden abgeschnitten — 21:30 Uhr
|
||||||
|
erscheint als 19:30 (Sonde 5). Fix: lokal formatieren.
|
||||||
|
|
||||||
|
**F13 · Eigener Werkzeug-Pfad eingetragen → Anzeige bleibt „NICHT GEFUNDEN"**
|
||||||
|
`kern/index.ts:245-254`: nach `werkzeug.*` wird `werkzeuge.senden()` nicht
|
||||||
|
angestoßen (auch `makemkvAppKey` löst keinen Schlüssel-Lauf aus). In der
|
||||||
|
Erst-Einrichtung (`ErstEinrichtung.tsx:112-121`) bleibt die rote Zeile
|
||||||
|
stehen, obwohl der Pfad stimmt. Fix: nach diesen Schlüsseln messen bzw.
|
||||||
|
prüfen.
|
||||||
|
|
||||||
|
**F14 · Erkennung kennt weder Originaltitel noch Akzente**
|
||||||
|
`zuordnung.ts:184-195,302-309`, `tmdb.ts:24-34` (kein `original_title`).
|
||||||
|
Siehe Kandidat 5. Fix: `original_title`/`original_name` mitvergleichen,
|
||||||
|
Akzente falten (NFD + Kombinationszeichen entfernen).
|
||||||
|
|
||||||
|
**F15 · Tray „Beenden" tötet einen laufenden Rip ohne Rückfrage**
|
||||||
|
`haupt/index.ts:278-281,383-387`, `tray.ts:291-297`: `app.quit()` → Kern
|
||||||
|
`kill()` → die Leine reißt makemkvcon/HandBrake mit — 40 Minuten Rip weg.
|
||||||
|
Fix: bei aktiven Vorgängen nachfragen.
|
||||||
|
|
||||||
|
**F16 · MakeMKV-Installer aus dem Internet Archive mit Adminrechten**
|
||||||
|
`beschaffen.ts:143-155,193-232`, `haupt/index.ts:206-216`: fällt der
|
||||||
|
Hersteller aus, wird eine EXE vom Archiv geladen und per UAC gestartet;
|
||||||
|
geprüft wird nur die Versions-Ressource (fälschbar). Für ein Programm im
|
||||||
|
Bekanntenkreis ein Risiko. Fix: SHA-256 einer bekannten Fassung
|
||||||
|
mitführen oder Archiv-Quelle nur nach Bestätigung.
|
||||||
|
|
||||||
|
### NIEDRIG
|
||||||
|
|
||||||
|
**F17** „Roh-Dateien löschen" und „Ordner löschen" ohne Rückfrage — ein Klick, 30 GB weg (`Karten.tsx:315-319,351-353`; `vorgaenge.ts:305-349`).
|
||||||
|
**F18** Eingabefelder speichern nur beim Verlassen; Enter und Bereichswechsel verlieren Tipparbeit (`bausteine.tsx:356-389`).
|
||||||
|
**F19** Dialoge ohne Escape, Fokus-Fang, `role="dialog"` (`bausteine.tsx:453-473`).
|
||||||
|
**F20** Toast „Disc erkannt" bis zu dreimal je Disc — Erkennung, Nachprüfung, „Ändern" (`haupt/index.ts:126-134`).
|
||||||
|
**F21** Poster-Fehler wird verschluckt: Rückgabe von `posterSpeichern` ignoriert (`ablegen.ts:385-387`, R4).
|
||||||
|
**F22** `finden()` ruft bei fehlendem MakeMKV dreimal `reg query … /s` synchron (bis 45 s) — bei jedem Aufruf, auch alle 15 s im Update-Lauf (`katalog.ts:168-203`).
|
||||||
|
**F23** NFO ohne `<uniqueid type="tmdb">` — Jellyfin muss über Titel/Jahr raten (`nfo.ts:187-209`).
|
||||||
|
**F24** Doppelter Zielordner bekommt den Zusatz „[bluray-2]" (`struktur.ts:158-170`, `vorgangsName.slice(0,8)`).
|
||||||
|
**F25** ASCII-Umlaute in Knopftexten: „auswaehlen", „Titel ueberspringen", „naechsten" (`LaufwerkKachel.tsx:161,413,415`).
|
||||||
|
**F26** Phase `rettet` fehlt in der Haupt-Liste aktiver Vorgänge (`haupt/index.ts:184`) — Update-Sperre und Taskleiste greifen im Rettungs-Abbild nicht.
|
||||||
|
**F27** „Neu komprimieren" wird auch für verschobene (unkomprimierte) Vorgänge angeboten (`vorgaenge.ts:245`) — Klick endet in „Roh-Dateien sind nicht mehr da".
|
||||||
|
**F28** Automatik schweigt bei Typ `unknown`/`cd` (`automatik.ts:77`) — kein Grund im Kasten.
|
||||||
|
**F29** Ton: `--first-audio` nimmt je Sprache genau EINE Spur (`handbrake.ts:56`) — Kommentar- oder Stereo-Spuren derselben Sprache fallen weg. Bewusst? Dann ins Fenster schreiben.
|
||||||
|
**F30** Audio-CD: `audioSpurLesen` blockiert den Kern synchron je Spur (`disc.ts:200-220`) — Abbrechen wirkt erst zwischen zwei Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Auffälligkeiten (keine Fehler, aber wissenswert)
|
||||||
|
|
||||||
|
**A1 · Der „Datenbank-Befund vom 01.09." ist gelöst — ein Messartefakt.**
|
||||||
|
Das Terminal der Claude-Desktop-App läuft in einem MSIX-Paket; alles,
|
||||||
|
was daraus startet, sieht `%APPDATA%`, `%LOCALAPPDATA%` und `HKCU`
|
||||||
|
virtualisiert (Schattenkopie unter
|
||||||
|
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\…`). Deshalb
|
||||||
|
zeigte `%APPDATA%\Rippy\rippy.db` hier Schema 3 vom 01.09. — die ECHTE
|
||||||
|
Datei (über `\\localhost\C$\…` gelesen) ist Schema 4, 36 864 Bytes, Stand
|
||||||
|
04.09. 22:02, mit allen Einstellungen. Rippy ist unschuldig. Folge: die
|
||||||
|
Lizenzzeilen 5054/5051 in `beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt`
|
||||||
|
kamen aus dem Paket-Register ohne Schlüssel — kein Beleg für deinen
|
||||||
|
echten Zustand. (Als Gedächtnis-Notiz für künftige Sitzungen hinterlegt.)
|
||||||
|
|
||||||
|
**A2 · Rippy schreibt in MakeMKVs eigene Einstellungen** (`HKCU\Software\MakeMKV`:
|
||||||
|
`app_Key`, `app_DefaultSelectionString`, `app_UpdateEnable`). Damit
|
||||||
|
nimmt auch das MakeMKV-GUI seitdem ALLE Spuren mit (größere Dateien beim
|
||||||
|
Handbetrieb) — beabsichtigt, aber nirgends im Fenster gesagt.
|
||||||
|
|
||||||
|
**A3 · Das Rettungs-Abbild ist bei AACS-Discs (Blu-ray/4K) ungeprüft.**
|
||||||
|
Ein Sektor-Abbild bleibt verschlüsselt; ob makemkvcon es als `iso:` ohne
|
||||||
|
Laufwerk öffnet, hängt an MakeMKVs Schlüsseldaten. Bei DVDs (CSS) sollte
|
||||||
|
es gehen. Am Gerät messen, bevor es jemand im Ernstfall braucht.
|
||||||
|
|
||||||
|
**A4 · 4K wird standardmäßig komprimiert** — mit dem 8-Bit-Preset
|
||||||
|
„H.265 … 2160p 4K" bzw. auf CPU mit „H.265 MKV 1080p30" (`presets.ts:23-49`,
|
||||||
|
`preset-empfehlung.ts:321-335`). HDR-Metadaten und 10 Bit gehen dabei
|
||||||
|
verloren, der Encode dauert Stunden. Das Docker-Rippy hatte „4K
|
||||||
|
verlustfrei" als Regel. Entscheid nötig: Standard „keine" für UHD oder
|
||||||
|
ein 10-Bit-Preset.
|
||||||
|
|
||||||
|
**A5 · Toter Schlüssel `transcodePreset`** in `presets.ts:47-48` — seit
|
||||||
|
5.5.0 nicht mehr in `PIPELINE_EINSTELLUNGEN`, kann nie greifen.
|
||||||
|
|
||||||
|
**A6 · Titel-Lesen: 300 s fest verdrahtet**, kein Regler, Meldung nennt
|
||||||
|
die Disc „beschädigt" (`erkennung.ts:354-356`) — siehe Kandidat 2.
|
||||||
|
|
||||||
|
**A7 · Der Klick-Beweis und die 450 Tests sind gut, aber blind für genau
|
||||||
|
die Funde oben:** kein Test mit Bestandsdateien im Season-Ordner, keiner
|
||||||
|
für mehrfaches `runde()`, keiner mit einem Key mit `@`, keiner für den
|
||||||
|
Rip-Start ohne Ablage, keiner für den Kern-Neustart.
|
||||||
|
|
||||||
|
**A8 · Sicherheit/Härtung ist sonst gut:** contextIsolation + sandbox im
|
||||||
|
Fenster, CSP im Bau, keine Shell-Aufrufe mit Nutzerdaten, PowerShell-
|
||||||
|
Pfad wird korrekt gequotet, Update nur über HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vorschläge zur Verbesserung — in dieser Reihenfolge
|
||||||
|
|
||||||
|
1. **F1 sofort** (eine Zeile plus Test, plus rote Ampel) — vor dem 30.09.
|
||||||
|
ist das der einzige Fund, der beide PCs betrifft.
|
||||||
|
2. **F2 + F4 + F6** — die drei „stillen" Fehler; jeder ist klein, alle
|
||||||
|
drei kosten Vertrauen („tut nichts, sagt nichts").
|
||||||
|
3. **F3** — Serien mit mehr als einer Disc gehen sonst kaputt; Test mit
|
||||||
|
vorhandenen Dateien im Ordner dazu.
|
||||||
|
4. **F8 + F9** — Aufräumen, das nicht zu viel und nicht zu wenig löscht.
|
||||||
|
5. **Kandidat 2** — Titel-Lesen-Grenze auf 10–15 min, Meldung ehrlich
|
||||||
|
(„die Disc braucht länger — läuft weiter / abbrechen"), Grenze in den
|
||||||
|
Einstellungen.
|
||||||
|
6. **F7** — Fortschritt von Vorgangs-Stand trennen; spürbar auf NAS.
|
||||||
|
7. **F14** — Originaltitel + Akzente: schlägt sich direkt in der
|
||||||
|
Trefferquote der Automatik nieder.
|
||||||
|
8. **F10/F11/F12/F13** — sichtbare Kleinigkeiten, je ein halber Nachmittag.
|
||||||
|
9. **F15/F17** — zwei Rückfragen, die Daten retten.
|
||||||
|
10. **A4** — dein Entscheid zu 4K.
|
||||||
|
11. **F31** (Nachtrag) — ist in 5.7.0 behoben; Filme mit wichtigem
|
||||||
|
HD-Ton, deren Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was gemessen wurde (nachvollziehbar)
|
||||||
|
|
||||||
|
* Typprüfung und Tests: `npm run typecheck`, `npx vitest run` — grün
|
||||||
|
(450 bestanden, 12 übersprungen).
|
||||||
|
* Sonden gegen den echten Code (Vitest, außerhalb des Repos):
|
||||||
|
S1 Akzente/Originaltitel · S2 Episoden-Umbenennung mit Bestand ·
|
||||||
|
S3 Wache-Takt-Ketten (9 statt ~5 Runden) · S4 Typ-Zuordnung ·
|
||||||
|
S5 UTC-Anzeige · S6 Labels der drei Filme · S7 99-Titel-Muster ·
|
||||||
|
S8 `halbeDateienEntfernen` · S9 Beta-Key von der echten Forum-Seite ·
|
||||||
|
S10 Rip-Start mit unerreichbarer Ablage.
|
||||||
|
* Electron 44: utilityProcess überlebt unbehandelte Promise-Ablehnung
|
||||||
|
(zwei Läufe, mit und ohne Horcher).
|
||||||
|
* Forum-Seite t=1053 heute geladen: Key mit `@`, „valid until end of
|
||||||
|
September 2026".
|
||||||
|
* Echte `rippy.db` dieses PCs über `\\localhost\C$`: Schema 4,
|
||||||
|
`makemkvAppKey` 68 Zeichen mit `@`, `ablage E:\Rippy`, `automatik aus`,
|
||||||
|
Jellyfin, TMDb- und OMDb-Schlüssel gesetzt, keine Vorgänge (Verlauf
|
||||||
|
leer), Bibliothek leer.
|
||||||
|
* Protokolle dieses PCs (02.–05.09.): Rippy 5.5.0 → 5.6.0 → 5.6.1,
|
||||||
|
Kizuna erkannt (95 %), Titel-Lauf 22–92 s, Laufwerk G am 02.09. mit
|
||||||
|
„2 Controllerfehler, 112 fehlerhafte Blöcke" (siehe F11 — ob das dein
|
||||||
|
BU40N war oder eine Platte, weiß der Bericht nicht).
|
||||||
|
* `makemkvcon64 -r --noscan info disc:9999` heute: nur 5042/5010 — ohne
|
||||||
|
Laufwerk keine Lizenzauskunft (F5).
|
||||||
|
|
||||||
|
Nichts davon hat etwas am Repo, an deinen Einstellungen oder am Register
|
||||||
|
verändert; die Sonden liegen im Sitzungs-Scratchpad.
|
||||||
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 445 KiB |
@@ -0,0 +1,109 @@
|
|||||||
|
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||||
|
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
|
||||||
|
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||||
|
const ctl = (typ, fn, methode, zugriff) =>
|
||||||
|
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||||
|
|
||||||
|
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||||
|
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||||
|
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||||
|
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||||
|
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||||
|
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||||
|
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||||
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
|
||||||
|
|
||||||
|
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||||
|
const DRIVE_CDROM = 5;
|
||||||
|
|
||||||
|
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||||
|
const maske = GetLogicalDrives();
|
||||||
|
const optische = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
if (!(maske & (1 << i))) continue;
|
||||||
|
const buchstabe = String.fromCharCode(65 + i);
|
||||||
|
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||||
|
}
|
||||||
|
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||||
|
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const lw = optische[0];
|
||||||
|
|
||||||
|
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||||
|
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||||
|
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||||
|
function oeffnen(zugriff) {
|
||||||
|
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||||
|
const adr = koffi.address(h);
|
||||||
|
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||||
|
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||||
|
return { h, fehler: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ohneMedium = oeffnen(0);
|
||||||
|
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||||
|
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||||
|
const mitLesen = oeffnen(GENERIC_READ);
|
||||||
|
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||||
|
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||||
|
|
||||||
|
const h = ohneMedium.h;
|
||||||
|
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||||
|
|
||||||
|
const rueck = Buffer.alloc(4);
|
||||||
|
|
||||||
|
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||||
|
const anfrage = Buffer.alloc(12);
|
||||||
|
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||||
|
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||||
|
const antwort = Buffer.alloc(1024);
|
||||||
|
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||||
|
const text = (off) => {
|
||||||
|
const start = antwort.readUInt32LE(off);
|
||||||
|
if (!start || start >= antwort.length) return '';
|
||||||
|
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||||
|
return antwort.toString('latin1', start, ende).trim();
|
||||||
|
};
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||||
|
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||||
|
} else {
|
||||||
|
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||||
|
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||||
|
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||||
|
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||||
|
|
||||||
|
// ── 5. Audio- oder Datenspur?
|
||||||
|
const typBuf = Buffer.alloc(1);
|
||||||
|
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||||
|
const t = typBuf[0];
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||||
|
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||||
|
} else {
|
||||||
|
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||||
|
const laenge = Buffer.alloc(8);
|
||||||
|
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||||
|
const bytes = laenge.readBigUInt64LE(0);
|
||||||
|
const gb = Number(bytes) / 1e9;
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||||
|
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||||
|
} else {
|
||||||
|
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(h);
|
||||||
|
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||||
|
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
|
||||||
|
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
|
||||||
|
//
|
||||||
|
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
|
||||||
|
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
|
||||||
|
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
|
||||||
|
// electron-updater ihn per spawn(…, { detached: true }) startet und
|
||||||
|
// `detached` unter Windows KEIN Breakaway setzt.
|
||||||
|
//
|
||||||
|
// Aufbau (drei Läufe, der äußere ruft die inneren):
|
||||||
|
// node beweise/leine-breakaway.js
|
||||||
|
// -> startet zweimal sich selbst als „innen":
|
||||||
|
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
|
||||||
|
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
|
||||||
|
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
|
||||||
|
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
|
||||||
|
// welcher Enkel noch lebt.
|
||||||
|
//
|
||||||
|
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
|
||||||
|
//
|
||||||
|
// Ausführen: node beweise/leine-breakaway.js
|
||||||
|
const { execFileSync, spawnSync } = require('node:child_process')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
|
||||||
|
|
||||||
|
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||||
|
const KILL_ON_JOB_CLOSE = 0x2000
|
||||||
|
const BREAKAWAY_OK = 0x0800
|
||||||
|
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||||
|
const CREATE_NO_WINDOW = 0x08000000
|
||||||
|
const STRUKTUR_GROESSE = 144
|
||||||
|
const LIMIT_FLAGS_OFFSET = 16
|
||||||
|
const STARTUPINFO_GROESSE = 104
|
||||||
|
const PROCESS_INFORMATION_GROESSE = 24
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll')
|
||||||
|
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
|
||||||
|
const SetInformationJobObject = kernel32.func(
|
||||||
|
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
|
||||||
|
)
|
||||||
|
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
const CreateProcessW = kernel32.func(
|
||||||
|
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
|
||||||
|
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
|
||||||
|
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
|
||||||
|
|
||||||
|
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
|
||||||
|
const job = CreateJobObjectW(null, null)
|
||||||
|
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||||
|
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
|
||||||
|
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
|
||||||
|
AssignProcessToJobObject(job, GetCurrentProcess())
|
||||||
|
|
||||||
|
const si = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||||
|
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||||
|
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||||
|
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
|
||||||
|
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
|
||||||
|
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
|
||||||
|
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
|
||||||
|
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
|
||||||
|
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
|
||||||
|
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
|
||||||
|
|
||||||
|
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
|
||||||
|
if (!ok) {
|
||||||
|
console.log(`PID=0 FEHLER=${GetLastError()}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
|
||||||
|
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lebt(pid) {
|
||||||
|
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
|
||||||
|
return (ergebnis.stdout || '').includes(String(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
function aussen() {
|
||||||
|
const faelle = [
|
||||||
|
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
|
||||||
|
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
|
||||||
|
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
|
||||||
|
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
|
||||||
|
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
|
||||||
|
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
|
||||||
|
]
|
||||||
|
for (const [name, modus, erwartetLebend] of faelle) {
|
||||||
|
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
|
||||||
|
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||||
|
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||||
|
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
|
||||||
|
// zugeschnappt, falls sie zuschnappt.
|
||||||
|
const nochDa = pid > 0 ? lebt(pid) : false
|
||||||
|
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
|
||||||
|
console.log(
|
||||||
|
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
|
||||||
|
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
|
||||||
|
)
|
||||||
|
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modus = process.argv[2] ?? ''
|
||||||
|
if (modus === 'alt') innen(false, false)
|
||||||
|
else if (modus === 'erlaubt-normal') innen(true, false)
|
||||||
|
else if (modus === 'erlaubt-aus') innen(true, true)
|
||||||
|
else aussen()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||||
|
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||||
|
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||||
|
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||||
|
const koffi = require('koffi');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const kernel32 = koffi.load('kernel32.dll');
|
||||||
|
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||||
|
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||||
|
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||||
|
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||||
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||||
|
|
||||||
|
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||||
|
const JobObjectExtendedLimitInformation = 9;
|
||||||
|
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||||
|
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||||
|
|
||||||
|
const job = CreateJobObjectW(null, null);
|
||||||
|
if (koffi.address(job) === 0n) {
|
||||||
|
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||||
|
|
||||||
|
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||||
|
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||||
|
const info = Buffer.alloc(144);
|
||||||
|
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||||
|
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||||
|
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||||
|
|
||||||
|
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||||
|
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||||
|
|
||||||
|
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||||
|
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||||
|
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||||
|
console.log('ELTERN-PID=' + process.pid);
|
||||||
|
console.log('KIND-PID=' + kind.pid);
|
||||||
|
|
||||||
|
// Offen halten, bis uns jemand hart abschiesst.
|
||||||
|
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||||
|
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
|
||||||
|
DRV:1,256,999,0,"","",""
|
||||||
|
DRV:2,256,999,0,"","",""
|
||||||
|
DRV:3,256,999,0,"","",""
|
||||||
|
DRV:4,256,999,0,"","",""
|
||||||
|
DRV:5,256,999,0,"","",""
|
||||||
|
DRV:6,256,999,0,"","",""
|
||||||
|
DRV:7,256,999,0,"","",""
|
||||||
|
DRV:8,256,999,0,"","",""
|
||||||
|
DRV:9,256,999,0,"","",""
|
||||||
|
DRV:10,256,999,0,"","",""
|
||||||
|
DRV:11,256,999,0,"","",""
|
||||||
|
DRV:12,256,999,0,"","",""
|
||||||
|
DRV:13,256,999,0,"","",""
|
||||||
|
DRV:14,256,999,0,"","",""
|
||||||
|
DRV:15,256,999,0,"","",""
|
||||||
|
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
|
||||||
|
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
|
||||||
|
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
|
||||||
|
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
|
||||||
|
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
|
||||||
|
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
|
||||||
|
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
|
||||||
|
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
|
||||||
|
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
|
||||||
|
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
|
||||||
|
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
|
||||||
|
TCOUNT:2
|
||||||
|
CINFO:1,6209,"Blu-ray-Disk"
|
||||||
|
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:28,0,"eng"
|
||||||
|
CINFO:29,0,"English"
|
||||||
|
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
|
||||||
|
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
|
||||||
|
CINFO:33,0,"0"
|
||||||
|
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:0,8,0,"6"
|
||||||
|
TINFO:0,9,0,"0:08:51"
|
||||||
|
TINFO:0,10,0,"1.1 GB"
|
||||||
|
TINFO:0,11,0,"1219086336"
|
||||||
|
TINFO:0,16,0,"00011.mpls"
|
||||||
|
TINFO:0,25,0,"6"
|
||||||
|
TINFO:0,26,0,"9,10,11,12,13,14"
|
||||||
|
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
|
||||||
|
TINFO:0,28,0,"eng"
|
||||||
|
TINFO:0,29,0,"English"
|
||||||
|
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
|
||||||
|
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:0,33,0,"0"
|
||||||
|
SINFO:0,0,1,6201,"Video"
|
||||||
|
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:0,0,6,0,"Mpeg4"
|
||||||
|
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,19,0,"1920x1080"
|
||||||
|
SINFO:0,0,20,0,"16:9"
|
||||||
|
SINFO:0,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:0,0,22,0,"0"
|
||||||
|
SINFO:0,0,28,0,"eng"
|
||||||
|
SINFO:0,0,29,0,"English"
|
||||||
|
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,0,33,0,"0"
|
||||||
|
SINFO:0,0,38,0,""
|
||||||
|
SINFO:0,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,1,1,6202,"Audio"
|
||||||
|
SINFO:0,1,2,5091,"Stereo"
|
||||||
|
SINFO:0,1,3,0,"deu"
|
||||||
|
SINFO:0,1,4,0,"German"
|
||||||
|
SINFO:0,1,5,0,"A_DTS"
|
||||||
|
SINFO:0,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,1,14,0,"2"
|
||||||
|
SINFO:0,1,17,0,"48000"
|
||||||
|
SINFO:0,1,18,0,"16"
|
||||||
|
SINFO:0,1,22,0,"1024"
|
||||||
|
SINFO:0,1,28,0,"eng"
|
||||||
|
SINFO:0,1,29,0,"English"
|
||||||
|
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,1,33,0,"100"
|
||||||
|
SINFO:0,1,38,0,""
|
||||||
|
SINFO:0,1,40,0,"stereo"
|
||||||
|
SINFO:0,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,2,1,6202,"Audio"
|
||||||
|
SINFO:0,2,2,5091,"Stereo"
|
||||||
|
SINFO:0,2,3,0,"deu"
|
||||||
|
SINFO:0,2,4,0,"German"
|
||||||
|
SINFO:0,2,5,0,"A_DTS"
|
||||||
|
SINFO:0,2,6,0,"DTS"
|
||||||
|
SINFO:0,2,7,0,"DTS"
|
||||||
|
SINFO:0,2,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,2,14,0,"2"
|
||||||
|
SINFO:0,2,17,0,"48000"
|
||||||
|
SINFO:0,2,22,0,"2304"
|
||||||
|
SINFO:0,2,28,0,"eng"
|
||||||
|
SINFO:0,2,29,0,"English"
|
||||||
|
SINFO:0,2,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,2,33,0,"100"
|
||||||
|
SINFO:0,2,38,0,"d"
|
||||||
|
SINFO:0,2,39,0,"Default"
|
||||||
|
SINFO:0,2,40,0,"stereo"
|
||||||
|
SINFO:0,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,3,1,6202,"Audio"
|
||||||
|
SINFO:0,3,2,5091,"Stereo"
|
||||||
|
SINFO:0,3,3,0,"deu"
|
||||||
|
SINFO:0,3,4,0,"German"
|
||||||
|
SINFO:0,3,5,0,"A_DTS"
|
||||||
|
SINFO:0,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,3,14,0,"2"
|
||||||
|
SINFO:0,3,17,0,"48000"
|
||||||
|
SINFO:0,3,18,0,"16"
|
||||||
|
SINFO:0,3,22,0,"1024"
|
||||||
|
SINFO:0,3,28,0,"eng"
|
||||||
|
SINFO:0,3,29,0,"English"
|
||||||
|
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,3,33,0,"100"
|
||||||
|
SINFO:0,3,38,0,""
|
||||||
|
SINFO:0,3,40,0,"stereo"
|
||||||
|
SINFO:0,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,4,1,6202,"Audio"
|
||||||
|
SINFO:0,4,2,5091,"Stereo"
|
||||||
|
SINFO:0,4,3,0,"deu"
|
||||||
|
SINFO:0,4,4,0,"German"
|
||||||
|
SINFO:0,4,5,0,"A_DTS"
|
||||||
|
SINFO:0,4,6,0,"DTS"
|
||||||
|
SINFO:0,4,7,0,"DTS"
|
||||||
|
SINFO:0,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,4,14,0,"2"
|
||||||
|
SINFO:0,4,17,0,"48000"
|
||||||
|
SINFO:0,4,22,0,"2304"
|
||||||
|
SINFO:0,4,28,0,"eng"
|
||||||
|
SINFO:0,4,29,0,"English"
|
||||||
|
SINFO:0,4,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,4,33,0,"100"
|
||||||
|
SINFO:0,4,38,0,""
|
||||||
|
SINFO:0,4,40,0,"stereo"
|
||||||
|
SINFO:0,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,5,1,6203,"Untertitel"
|
||||||
|
SINFO:0,5,3,0,"deu"
|
||||||
|
SINFO:0,5,4,0,"German"
|
||||||
|
SINFO:0,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,5,6,0,"PGS"
|
||||||
|
SINFO:0,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,5,22,0,"0"
|
||||||
|
SINFO:0,5,28,0,"eng"
|
||||||
|
SINFO:0,5,29,0,"English"
|
||||||
|
SINFO:0,5,30,0,"PGS German"
|
||||||
|
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,5,33,0,"100"
|
||||||
|
SINFO:0,5,38,0,""
|
||||||
|
SINFO:0,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,6,1,6203,"Untertitel"
|
||||||
|
SINFO:0,6,3,0,"deu"
|
||||||
|
SINFO:0,6,4,0,"German"
|
||||||
|
SINFO:0,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,6,6,0,"PGS"
|
||||||
|
SINFO:0,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,6,22,0,"6144"
|
||||||
|
SINFO:0,6,28,0,"eng"
|
||||||
|
SINFO:0,6,29,0,"English"
|
||||||
|
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,6,33,0,"100"
|
||||||
|
SINFO:0,6,38,0,"d"
|
||||||
|
SINFO:0,6,39,0,"Default"
|
||||||
|
SINFO:0,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,7,1,6203,"Untertitel"
|
||||||
|
SINFO:0,7,3,0,"deu"
|
||||||
|
SINFO:0,7,4,0,"German"
|
||||||
|
SINFO:0,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,7,6,0,"PGS"
|
||||||
|
SINFO:0,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,7,22,0,"0"
|
||||||
|
SINFO:0,7,28,0,"eng"
|
||||||
|
SINFO:0,7,29,0,"English"
|
||||||
|
SINFO:0,7,30,0,"PGS German"
|
||||||
|
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,7,33,0,"100"
|
||||||
|
SINFO:0,7,38,0,""
|
||||||
|
SINFO:0,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,8,1,6203,"Untertitel"
|
||||||
|
SINFO:0,8,3,0,"deu"
|
||||||
|
SINFO:0,8,4,0,"German"
|
||||||
|
SINFO:0,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,8,6,0,"PGS"
|
||||||
|
SINFO:0,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,8,22,0,"6144"
|
||||||
|
SINFO:0,8,28,0,"eng"
|
||||||
|
SINFO:0,8,29,0,"English"
|
||||||
|
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,8,33,0,"100"
|
||||||
|
SINFO:0,8,38,0,""
|
||||||
|
SINFO:0,8,42,5088,"( Lossless conversion )"
|
||||||
|
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:1,8,0,"13"
|
||||||
|
TINFO:1,9,0,"1:34:45"
|
||||||
|
TINFO:1,10,0,"16.4 GB"
|
||||||
|
TINFO:1,11,0,"17668571136"
|
||||||
|
TINFO:1,16,0,"00003.mpls"
|
||||||
|
TINFO:1,25,0,"1"
|
||||||
|
TINFO:1,26,0,"7"
|
||||||
|
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
|
||||||
|
TINFO:1,28,0,"eng"
|
||||||
|
TINFO:1,29,0,"English"
|
||||||
|
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
|
||||||
|
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:1,33,0,"0"
|
||||||
|
SINFO:1,0,1,6201,"Video"
|
||||||
|
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:1,0,6,0,"Mpeg4"
|
||||||
|
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,19,0,"1920x1080"
|
||||||
|
SINFO:1,0,20,0,"16:9"
|
||||||
|
SINFO:1,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:1,0,22,0,"0"
|
||||||
|
SINFO:1,0,28,0,"eng"
|
||||||
|
SINFO:1,0,29,0,"English"
|
||||||
|
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,0,33,0,"0"
|
||||||
|
SINFO:1,0,38,0,""
|
||||||
|
SINFO:1,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,1,1,6202,"Audio"
|
||||||
|
SINFO:1,1,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,1,3,0,"deu"
|
||||||
|
SINFO:1,1,4,0,"German"
|
||||||
|
SINFO:1,1,5,0,"A_DTS"
|
||||||
|
SINFO:1,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,1,14,0,"6"
|
||||||
|
SINFO:1,1,17,0,"48000"
|
||||||
|
SINFO:1,1,18,0,"16"
|
||||||
|
SINFO:1,1,22,0,"1024"
|
||||||
|
SINFO:1,1,28,0,"eng"
|
||||||
|
SINFO:1,1,29,0,"English"
|
||||||
|
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
|
||||||
|
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,1,33,0,"100"
|
||||||
|
SINFO:1,1,38,0,""
|
||||||
|
SINFO:1,1,40,0,"5.1(side)"
|
||||||
|
SINFO:1,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,2,1,6202,"Audio"
|
||||||
|
SINFO:1,2,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,2,3,0,"deu"
|
||||||
|
SINFO:1,2,4,0,"German"
|
||||||
|
SINFO:1,2,5,0,"A_DTS"
|
||||||
|
SINFO:1,2,6,0,"DTS"
|
||||||
|
SINFO:1,2,7,0,"DTS"
|
||||||
|
SINFO:1,2,13,0,"1.5 Mb/s"
|
||||||
|
SINFO:1,2,14,0,"6"
|
||||||
|
SINFO:1,2,17,0,"48000"
|
||||||
|
SINFO:1,2,22,0,"2304"
|
||||||
|
SINFO:1,2,28,0,"eng"
|
||||||
|
SINFO:1,2,29,0,"English"
|
||||||
|
SINFO:1,2,30,0,"DTS Surround 5.1 German"
|
||||||
|
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,2,33,0,"100"
|
||||||
|
SINFO:1,2,38,0,"d"
|
||||||
|
SINFO:1,2,39,0,"Default"
|
||||||
|
SINFO:1,2,40,0,"5.1(side)"
|
||||||
|
SINFO:1,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,3,1,6202,"Audio"
|
||||||
|
SINFO:1,3,2,5091,"Stereo"
|
||||||
|
SINFO:1,3,3,0,"jpn"
|
||||||
|
SINFO:1,3,4,0,"Japanese"
|
||||||
|
SINFO:1,3,5,0,"A_DTS"
|
||||||
|
SINFO:1,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,3,14,0,"2"
|
||||||
|
SINFO:1,3,17,0,"48000"
|
||||||
|
SINFO:1,3,18,0,"16"
|
||||||
|
SINFO:1,3,22,0,"1024"
|
||||||
|
SINFO:1,3,28,0,"eng"
|
||||||
|
SINFO:1,3,29,0,"English"
|
||||||
|
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
|
||||||
|
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,3,33,0,"100"
|
||||||
|
SINFO:1,3,38,0,""
|
||||||
|
SINFO:1,3,40,0,"stereo"
|
||||||
|
SINFO:1,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,4,1,6202,"Audio"
|
||||||
|
SINFO:1,4,2,5091,"Stereo"
|
||||||
|
SINFO:1,4,3,0,"jpn"
|
||||||
|
SINFO:1,4,4,0,"Japanese"
|
||||||
|
SINFO:1,4,5,0,"A_DTS"
|
||||||
|
SINFO:1,4,6,0,"DTS"
|
||||||
|
SINFO:1,4,7,0,"DTS"
|
||||||
|
SINFO:1,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:1,4,14,0,"2"
|
||||||
|
SINFO:1,4,17,0,"48000"
|
||||||
|
SINFO:1,4,22,0,"2304"
|
||||||
|
SINFO:1,4,28,0,"eng"
|
||||||
|
SINFO:1,4,29,0,"English"
|
||||||
|
SINFO:1,4,30,0,"DTS Stereo Japanese"
|
||||||
|
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,4,33,0,"100"
|
||||||
|
SINFO:1,4,38,0,""
|
||||||
|
SINFO:1,4,40,0,"stereo"
|
||||||
|
SINFO:1,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,5,1,6203,"Untertitel"
|
||||||
|
SINFO:1,5,3,0,"deu"
|
||||||
|
SINFO:1,5,4,0,"German"
|
||||||
|
SINFO:1,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,5,6,0,"PGS"
|
||||||
|
SINFO:1,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,5,22,0,"0"
|
||||||
|
SINFO:1,5,28,0,"eng"
|
||||||
|
SINFO:1,5,29,0,"English"
|
||||||
|
SINFO:1,5,30,0,"PGS German"
|
||||||
|
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,5,33,0,"100"
|
||||||
|
SINFO:1,5,38,0,""
|
||||||
|
SINFO:1,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,6,1,6203,"Untertitel"
|
||||||
|
SINFO:1,6,3,0,"deu"
|
||||||
|
SINFO:1,6,4,0,"German"
|
||||||
|
SINFO:1,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,6,6,0,"PGS"
|
||||||
|
SINFO:1,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,6,22,0,"6144"
|
||||||
|
SINFO:1,6,28,0,"eng"
|
||||||
|
SINFO:1,6,29,0,"English"
|
||||||
|
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,6,33,0,"100"
|
||||||
|
SINFO:1,6,38,0,"d"
|
||||||
|
SINFO:1,6,39,0,"Default"
|
||||||
|
SINFO:1,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,7,1,6203,"Untertitel"
|
||||||
|
SINFO:1,7,3,0,"deu"
|
||||||
|
SINFO:1,7,4,0,"German"
|
||||||
|
SINFO:1,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,7,6,0,"PGS"
|
||||||
|
SINFO:1,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,7,22,0,"0"
|
||||||
|
SINFO:1,7,28,0,"eng"
|
||||||
|
SINFO:1,7,29,0,"English"
|
||||||
|
SINFO:1,7,30,0,"PGS German"
|
||||||
|
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,7,33,0,"100"
|
||||||
|
SINFO:1,7,38,0,""
|
||||||
|
SINFO:1,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,8,1,6203,"Untertitel"
|
||||||
|
SINFO:1,8,3,0,"deu"
|
||||||
|
SINFO:1,8,4,0,"German"
|
||||||
|
SINFO:1,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,8,6,0,"PGS"
|
||||||
|
SINFO:1,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,8,22,0,"6144"
|
||||||
|
SINFO:1,8,28,0,"eng"
|
||||||
|
SINFO:1,8,29,0,"English"
|
||||||
|
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,8,33,0,"100"
|
||||||
|
SINFO:1,8,38,0,""
|
||||||
|
SINFO:1,8,42,5088,"( Lossless conversion )"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||||
|
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||||
|
try {
|
||||||
|
const s = require('node:sqlite');
|
||||||
|
const db = new s.DatabaseSync(':memory:');
|
||||||
|
db.exec('CREATE TABLE t (a TEXT)');
|
||||||
|
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||||
|
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||||
|
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||||
|
} catch (e) {
|
||||||
|
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||||
|
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||||
|
//
|
||||||
|
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||||
|
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||||
|
const { app, utilityProcess } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||||
|
kind.on('message', (antwort) => {
|
||||||
|
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||||
|
app.exit(antwort.ok ? 0 : 1);
|
||||||
|
});
|
||||||
|
kind.on('exit', (code) => {
|
||||||
|
console.log('Kern beendet mit ' + code);
|
||||||
|
if (code !== 0) app.exit(1);
|
||||||
|
});
|
||||||
|
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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
|
||||||
@@ -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 import drives as _laufwerks_schicht
|
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
|
||||||
@@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key
|
|||||||
# test_treiberwahl.py.
|
# test_treiberwahl.py.
|
||||||
detection = _laufwerks_schicht.treiber()
|
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).
|
||||||
|
|
||||||
@@ -80,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]:
|
||||||
@@ -279,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():
|
||||||
@@ -303,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
|
||||||
@@ -395,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
|
||||||
@@ -446,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)
|
||||||
|
|||||||
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
|||||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||||
monkeypatch.setattr(main.db, "get_settings", lambda: {})
|
#
|
||||||
|
# `*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())
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
@@ -401,8 +409,526 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
|||||||
assert daten["modus"] in ("standalone", "verteilt")
|
assert daten["modus"] in ("standalone", "verteilt")
|
||||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||||
"container_pfade", "werkzeuge_verwalten"}
|
"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())
|
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||||
if not daten["im_container"]:
|
if not daten["im_container"]:
|
||||||
assert daten["hilfe_befehl"] == ""
|
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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
|
|||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||||
import { BetriebProvider } from './lib/useBetrieb'
|
import { BetriebProvider } from './lib/useBetrieb'
|
||||||
|
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
@@ -51,10 +52,45 @@ function AppInhalt() {
|
|||||||
// 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 }[] = [
|
||||||
@@ -64,7 +100,22 @@ function AppInhalt() {
|
|||||||
{ 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 (
|
||||||
@@ -111,10 +162,19 @@ function AppInhalt() {
|
|||||||
|
|
||||||
{/* 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>
|
||||||
)
|
)
|
||||||
@@ -128,10 +188,12 @@ export default function App() {
|
|||||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||||
return (
|
return (
|
||||||
|
<Fehlergrenze>
|
||||||
<BetriebProvider>
|
<BetriebProvider>
|
||||||
<EventStreamProvider>
|
<EventStreamProvider>
|
||||||
<AppInhalt />
|
<AppInhalt />
|
||||||
</EventStreamProvider>
|
</EventStreamProvider>
|
||||||
</BetriebProvider>
|
</BetriebProvider>
|
||||||
|
</Fehlergrenze>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,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 {
|
||||||
@@ -179,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>
|
||||||
|
|||||||
@@ -45,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 => (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface BetriebsFaehigkeiten {
|
|||||||
container_pfade: boolean
|
container_pfade: boolean
|
||||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||||
werkzeuge_verwalten: boolean
|
werkzeuge_verwalten: boolean
|
||||||
|
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||||
|
frei_blaettern: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Betrieb {
|
export interface Betrieb {
|
||||||
@@ -54,6 +56,8 @@ export interface Betrieb {
|
|||||||
kann: BetriebsFaehigkeiten
|
kann: BetriebsFaehigkeiten
|
||||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||||
ablage_vorgabe: string
|
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
|
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||||
hilfe_befehl: string
|
hilfe_befehl: string
|
||||||
@@ -71,8 +75,13 @@ const VORGABE: Betrieb = {
|
|||||||
freigaben_einhaengen: true,
|
freigaben_einhaengen: true,
|
||||||
container_pfade: true,
|
container_pfade: true,
|
||||||
werkzeuge_verwalten: false,
|
werkzeuge_verwalten: false,
|
||||||
|
frei_blaettern: false,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: '/app/media',
|
// 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',
|
hilfe_befehl: 'docker compose -p rippy ps',
|
||||||
geladen: false,
|
geladen: false,
|
||||||
}
|
}
|
||||||
@@ -100,8 +109,10 @@ export function BetriebProvider({ children }: { children: ReactNode }) {
|
|||||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||||
container_pfade: !!d.kann.container_pfade,
|
container_pfade: !!d.kann.container_pfade,
|
||||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||||
|
frei_blaettern: !!d.kann.frei_blaettern,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||||
|
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||||
hilfe_befehl: d.hilfe_befehl || '',
|
hilfe_befehl: d.hilfe_befehl || '',
|
||||||
geladen: true,
|
geladen: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,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[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,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
|
||||||
@@ -328,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 => {
|
||||||
@@ -584,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
|
||||||
@@ -749,10 +791,45 @@ export default function Dashboard() {
|
|||||||
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" />
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'}
|
{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'}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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'
|
||||||
@@ -213,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)
|
||||||
@@ -253,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 {
|
||||||
@@ -940,15 +981,40 @@ 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="">
|
<option value="">
|
||||||
{betrieb.kann.container_pfade
|
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||||
? 'Container-Platte (Standard) — klein, nur für DVD/Blu-ray'
|
|
||||||
: `Standard (${betrieb.ablage_vorgabe || 'neben der Ablage'})`}
|
|
||||||
</option>
|
</option>
|
||||||
{ziele.map(z => (
|
{ziele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<option key={z.path} value={z.path}>
|
||||||
@@ -957,6 +1023,27 @@ 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">
|
||||||
@@ -1011,11 +1098,11 @@ export default function SettingsPage() {
|
|||||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||||
Bedienung, die nichts bedient. */
|
Bedienung, die nichts bedient. */
|
||||||
<Input
|
<OrdnerWaehler
|
||||||
label="Ordner"
|
label="Ordner"
|
||||||
value={settings.outputDir}
|
value={settings.outputDir}
|
||||||
placeholder={betrieb.ablage_vorgabe}
|
placeholder={betrieb.ablage_vorgabe}
|
||||||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
@@ -1249,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"
|
||||||
@@ -1256,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,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from rippy import store as db
|
from rippy import store as db
|
||||||
from rippy.rip import makemkv_daten
|
from rippy.rip import makemkv_aufruf, makemkv_daten
|
||||||
import medien
|
import medien
|
||||||
from rippy.core import notify
|
from rippy.core import notify
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
|||||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
def _zielbasis(target_dir, disc_type: str, wurzeln=None) -> str:
|
||||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||||
|
|
||||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||||
@@ -167,16 +167,70 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||||
"""
|
"""
|
||||||
|
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
if target_dir:
|
if target_dir:
|
||||||
normalisiert = posixpath.normpath(target_dir)
|
normalisiert = _normalisiert(target_dir)
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
# Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
|
||||||
|
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
|
||||||
|
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
def _betriebs_wurzeln() -> tuple:
|
||||||
|
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das hier gebraucht wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch —
|
||||||
|
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
|
||||||
|
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
|
||||||
|
|
||||||
|
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
|
||||||
|
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
|
||||||
|
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
|
||||||
|
Wahl** — ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
|
||||||
|
also fiel er still auf den Container-Standard zurück.
|
||||||
|
|
||||||
|
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
|
||||||
|
bestellt hat („kannst du noch einbauen das man den arbeitsordner … setzen
|
||||||
|
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
|
||||||
|
der Worker ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
|
||||||
|
`frei` ist falsch.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisiert(wert: str) -> str:
|
||||||
|
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
|
||||||
|
|
||||||
|
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
|
||||||
|
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
|
||||||
|
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
|
||||||
|
else posixpath.normpath(wert)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
||||||
|
wurzeln=None) -> str:
|
||||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
`workDir` → Container-Default /app/temp/raw.
|
`workDir` → Vorgabe dieses Betriebs (Container: /app/temp/raw).
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
@@ -187,26 +241,48 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
|||||||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||||
denen niemand gefragt wird.
|
denen niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
|
||||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
|
||||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
|
||||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||||
wert = (kandidat or "").strip()
|
wert = (kandidat or "").strip()
|
||||||
if wert:
|
if not wert:
|
||||||
normalisiert = posixpath.normpath(wert)
|
continue
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
normalisiert = _normalisiert(wert)
|
||||||
|
# Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
|
||||||
|
# Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
|
||||||
|
# von /app/media wäre dort ein Pfad ins Nichts.
|
||||||
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return RAW_DIR
|
return vorgabe
|
||||||
|
|
||||||
|
|
||||||
def _frei_bytes(pfad: str) -> int:
|
def _frei_bytes(pfad: str) -> int:
|
||||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||||||
kandidat = pfad
|
|
||||||
|
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
An dieser Stelle stand sie als eigene Schleife:
|
||||||
|
|
||||||
while kandidat and not os.path.exists(kandidat):
|
while kandidat and not os.path.exists(kandidat):
|
||||||
kandidat = os.path.dirname(kandidat)
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
|
||||||
|
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an
|
||||||
|
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
|
||||||
|
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
|
||||||
|
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
|
||||||
|
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
|
||||||
|
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
|
||||||
|
Meldung stehen, und im Protokoll stuende nichts, womit man das
|
||||||
|
aufklären koennte.
|
||||||
|
|
||||||
|
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit
|
||||||
|
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
|
||||||
|
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
|
||||||
|
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return shutil.disk_usage(kandidat or "/").free
|
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||||||
except OSError:
|
except OSError:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -243,17 +319,27 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
|||||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
# WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
|
||||||
try:
|
# die settings.conf. Begruendung und Messung in
|
||||||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
# `rippy.rip.makemkv_aufruf.key_ablegen`.
|
||||||
alt = f.read()
|
#
|
||||||
except OSError:
|
# UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
|
||||||
alt = ""
|
# ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
|
||||||
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
# einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
|
||||||
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
# paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
|
||||||
except OSError as e:
|
# laeuft und sonst am ersten Titel scheitert.
|
||||||
|
from ripping import werkzeug
|
||||||
|
|
||||||
|
# Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start
|
||||||
|
# tut — nur in der Windows-Form.
|
||||||
|
makemkv_aufruf.einstellungen_sicherstellen()
|
||||||
|
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
|
||||||
|
if not urteil["ok"]:
|
||||||
|
db.add_log("warning", "worker",
|
||||||
|
"MakeMKV nimmt den hinterlegten Beta-Key nicht an: %s"
|
||||||
|
% urteil["grund"])
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||||
return
|
return
|
||||||
gesehen.add(text)
|
gesehen.add(text)
|
||||||
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
|
||||||
|
# sind uebersetzt, die Nummern nicht — sie sind die einzige
|
||||||
|
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
|
||||||
|
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
|
||||||
|
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
|
||||||
|
# ersatzweise an einer URL im Text festhalten.
|
||||||
|
db.add_log("info", "makemkv",
|
||||||
|
f"Job {job_id}: MSG {code} — {text[:300]}")
|
||||||
|
|
||||||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
@@ -675,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
"Normale BD/DVD gehen weiterhin."
|
"Normale BD/DVD gehen weiterhin."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
|
||||||
|
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
|
||||||
|
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
|
||||||
|
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
|
||||||
|
# eine halbe Disc für eine ganze.
|
||||||
|
if ergebnis.get("lesefehler"):
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
|
||||||
|
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
|
||||||
|
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
|
||||||
|
"von innen nach außen, nicht kreisend) oder ein anderes "
|
||||||
|
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
|
||||||
|
)
|
||||||
|
|
||||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||||
#
|
#
|
||||||
@@ -802,7 +910,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||||
final_container: str, final_lokal: str) -> str:
|
final_container: str, final_lokal: str,
|
||||||
|
fremd=None) -> str:
|
||||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||||
|
|
||||||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||||
@@ -824,7 +933,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
|||||||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||||
"""
|
"""
|
||||||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||||
fremder_worker = not os.path.isdir("/app")
|
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
|
||||||
|
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
|
||||||
|
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
|
||||||
|
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
|
||||||
|
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
|
||||||
|
#
|
||||||
|
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
|
||||||
|
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
|
||||||
|
# damit an den laufenden Rechner gebunden.
|
||||||
|
if fremd is None:
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
fremd = _extern() == "ja"
|
||||||
|
fremder_worker = fremd
|
||||||
|
|
||||||
for zweck, container, lokal in (
|
for zweck, container, lokal in (
|
||||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||||
@@ -1002,7 +1124,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"error": (
|
"error": (
|
||||||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||||
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
|
||||||
|
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
|
||||||
|
# Roh-Datei retten will, sucht sonst am falschen Ort.
|
||||||
|
f"{hb.get('error')} — Roh-Datei bleibt unter "
|
||||||
|
f"{os.path.dirname(quelle)} erhalten"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
_job_abschliessen(job_id, ergebnis)
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
|||||||
@@ -8,13 +8,39 @@ 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 rippy.platform.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
|
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:
|
def _hb() -> str:
|
||||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||||
|
|
||||||
@@ -166,7 +192,7 @@ def hole_handbrake_presets() -> list:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--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 ""))
|
||||||
@@ -279,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--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):
|
||||||
@@ -343,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", ""),
|
||||||
@@ -368,7 +404,7 @@ def werkzeug_versionen() -> dict:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--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 ""))
|
||||||
@@ -402,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:
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ from rippy.drives.linux import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
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
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||||
@@ -86,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).
|
||||||
|
|
||||||
@@ -102,7 +123,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
|||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
makemkv_quelle(device_path),
|
||||||
str(titel),
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
@@ -257,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(
|
||||||
[werkzeug("makemkv"), "-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)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,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:
|
||||||
@@ -278,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):
|
||||||
@@ -330,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(
|
||||||
[werkzeug("makemkv"), "-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:
|
||||||
@@ -360,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("handbrake"), "--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):
|
||||||
@@ -474,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:
|
||||||
@@ -499,7 +532,7 @@ 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 = [
|
||||||
@@ -508,11 +541,46 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
"--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)]
|
||||||
@@ -577,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:
|
||||||
@@ -600,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:
|
||||||
@@ -629,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
|
||||||
@@ -640,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
|
||||||
@@ -657,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",
|
||||||
@@ -671,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,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.
|
||||||
@@ -723,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:
|
||||||
@@ -730,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",
|
||||||
@@ -748,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:
|
||||||
@@ -758,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:
|
||||||
@@ -786,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 "")
|
||||||
@@ -838,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -121,24 +121,33 @@ 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 ablauf as 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():
|
||||||
@@ -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():
|
||||||
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
import ablauf as tasks
|
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 ablauf as 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,9 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
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
|
||||||
@@ -375,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:
|
||||||
@@ -390,7 +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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
|
"""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
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 kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
@@ -519,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]
|
||||||
|
|||||||
@@ -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,114 @@
|
|||||||
|
# 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). |
|
||||||
|
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Speicher-Karte → Rippen → Dialog → Sprachen → Folgen → Platz-Prüfung → Rip → Warteschlange → Kompression → Arbeitsordner (Roh lokal, Zwischenordner geräumt) → Ablage → Verlauf → Bilanz → Roh-Dateien → Einstellungen (Sprach-Chips, Durchsuchen, Karten Automatik/Benachrichtigung/Allgemein/Update). Seit 5.5.0 mit getrenntem Arbeitsordner und Automatik aus (der Beweis klickt selbst). 5.6.0: dazu die Untertitel-Vorwahl (nur bei Extras), „anderer Film …" je Titel, die Restzeit in der Kompressions-Karte (das nachgebaute HandBrake schreibt die ETA-Klammer wie das Binary), die Kachel-Kurzzeile, die Vorgangs-Leiste (TITEL LESEN ✓ → RIPPEN aktiv → KOMPRIMIEREN → ABLEGEN, keine „PHASE x/4"-Texte mehr) und der Hell-Modus (Bild `07-hell-modus.png`). Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
|
||||||
|
|
||||||
|
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,384 @@
|
|||||||
|
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
|
||||||
|
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
|
||||||
|
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
|
||||||
|
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
|
||||||
|
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
|
||||||
|
// Ablage → Roh-Dateien → löschen.
|
||||||
|
//
|
||||||
|
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
|
||||||
|
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
|
||||||
|
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
|
||||||
|
// (Doku »Electron« bei playwright.dev, _electron.launch).
|
||||||
|
//
|
||||||
|
// npm run klick (baut vorher)
|
||||||
|
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
|
||||||
|
import { _electron as electron } from 'playwright'
|
||||||
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const profil = join(tmpdir(), 'rippy-klick')
|
||||||
|
const ablage = join(profil, 'ablage')
|
||||||
|
// 5.5.0: Arbeitsordner getrennt von der Ablage — Roh und Encode liegen
|
||||||
|
// lokal, nur das Fertige wandert (hier: von Ordner zu Ordner im Profil).
|
||||||
|
const arbeit = join(profil, 'arbeit')
|
||||||
|
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
|
||||||
|
|
||||||
|
rmSync(profil, { recursive: true, force: true })
|
||||||
|
mkdirSync(profil, { recursive: true })
|
||||||
|
mkdirSync(bilder, { recursive: true })
|
||||||
|
// Alte Bilder weg — sonst bleiben Nummern früherer Fassungen liegen.
|
||||||
|
for (const name of readdirSync(bilder)) if (name.endsWith('.png')) rmSync(join(bilder, name))
|
||||||
|
|
||||||
|
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
|
||||||
|
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
|
||||||
|
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
|
||||||
|
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
|
||||||
|
{
|
||||||
|
const db = new DatabaseSync(join(profil, 'rippy.db'))
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
|
||||||
|
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
|
||||||
|
setzen.run('setup.done', 'true')
|
||||||
|
setzen.run('ablage', ablage)
|
||||||
|
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
|
||||||
|
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
|
||||||
|
setzen.run('audioSprachen', 'deu,eng')
|
||||||
|
setzen.run('untertitelSprachen', 'deu')
|
||||||
|
setzen.run('rohAufbewahrung', 'behalten')
|
||||||
|
setzen.run('autoAuswurf', 'false')
|
||||||
|
setzen.run('arbeitsordner', arbeit)
|
||||||
|
// Die Automatik bleibt aus: Der Beweis klickt selbst.
|
||||||
|
setzen.run('automatik', 'aus')
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (env) =>
|
||||||
|
electron.launch({
|
||||||
|
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
|
||||||
|
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
|
||||||
|
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1', ...env },
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
let app = await start({})
|
||||||
|
async function fenster() {
|
||||||
|
const s = await app.firstWindow()
|
||||||
|
s.setDefaultTimeout(30_000)
|
||||||
|
s.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
let seite = await fenster()
|
||||||
|
|
||||||
|
let nr = 0
|
||||||
|
async function bild(name) {
|
||||||
|
nr += 1
|
||||||
|
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
|
||||||
|
await seite.screenshot({ path: pfad, fullPage: true })
|
||||||
|
console.log(`KLICK bild: ${pfad}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schritt(name, tun) {
|
||||||
|
try {
|
||||||
|
await tun()
|
||||||
|
console.log(`KLICK ✓ ${name}`)
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
|
||||||
|
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = () => seite.locator('div.fixed.inset-0').last()
|
||||||
|
|
||||||
|
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
|
||||||
|
await seite.getByText('LAUFWERK Q:').waitFor()
|
||||||
|
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
|
||||||
|
})
|
||||||
|
await bild('uebersicht')
|
||||||
|
|
||||||
|
await schritt('Speicher-Karte misst Arbeitsordner und Ablage getrennt, die Fußleiste zeigt FREI', async () => {
|
||||||
|
await seite.getByText('SPEICHER UND BILANZ').waitFor()
|
||||||
|
await seite.getByText('ARBEITSORDNER (LOKAL)').waitFor()
|
||||||
|
await seite.getByText('ABLAGE (SERVER)').waitFor()
|
||||||
|
await seite.getByText(/^FREI [0-9]/).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Keine Bibliothek mehr — Übersicht und Einstellungen sind die Bereiche', async () => {
|
||||||
|
const n = await seite.getByRole('button', { name: 'Bibliothek' }).count()
|
||||||
|
if (n !== 0) throw new Error('Bibliothek-Knopf ist noch da')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Rippen → Dialog öffnet sich', async () => {
|
||||||
|
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
|
||||||
|
await seite.getByText('Was soll gerippt werden?').waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Titel-Lauf liefert drei Titel', async () => {
|
||||||
|
await dialog().getByText('Titel 0', { exact: true }).waitFor()
|
||||||
|
await dialog().getByText('Titel 1', { exact: true }).waitFor()
|
||||||
|
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
|
||||||
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
|
||||||
|
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
|
||||||
|
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
|
||||||
|
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
|
||||||
|
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
|
||||||
|
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
|
||||||
|
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
|
||||||
|
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
|
||||||
|
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
|
||||||
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
|
||||||
|
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||||
|
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
|
||||||
|
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
|
||||||
|
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
|
||||||
|
await dialog().getByText(/2 von 3 gewählt/).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Film …" je Titel', async () => {
|
||||||
|
// Rippys Vorschlag macht bei zwei langen Titeln den zweiten zum Extra —
|
||||||
|
// erst beide auf Hauptfilm, dann ist klar: keine Vorwahl bei Hauptfilmen.
|
||||||
|
const rollen = dialog().locator('select')
|
||||||
|
await rollen.nth(0).selectOption('hauptfilm')
|
||||||
|
await rollen.nth(1).selectOption('hauptfilm')
|
||||||
|
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl schon bei Hauptfilmen sichtbar')
|
||||||
|
await rollen.nth(1).selectOption('extra')
|
||||||
|
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
||||||
|
const vorwahl = dialog().locator('select').nth(2)
|
||||||
|
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
|
||||||
|
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
|
||||||
|
await vorwahl.selectOption('5')
|
||||||
|
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
||||||
|
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
||||||
|
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||||
|
await dialog().getByPlaceholder('Filmtitel suchen …').waitFor()
|
||||||
|
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||||
|
await rollen.nth(1).selectOption('hauptfilm')
|
||||||
|
if ((await dialog().getByText('BEIM ABSPIELEN AN').count()) !== 0) throw new Error('Vorwahl bleibt nach Rollenwechsel sichtbar')
|
||||||
|
})
|
||||||
|
await bild('dialog-vorwahl')
|
||||||
|
|
||||||
|
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
|
||||||
|
const rollen = dialog().locator('select')
|
||||||
|
await rollen.nth(0).selectOption('folge')
|
||||||
|
await rollen.nth(1).selectOption('folge')
|
||||||
|
await dialog().getByText('AB FOLGE').waitFor()
|
||||||
|
await dialog().getByPlaceholder('automatisch').fill('3')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
|
||||||
|
await dialog().getByText(/Serien\\/).waitFor()
|
||||||
|
await dialog().getByText(/Season 01/).waitFor()
|
||||||
|
await dialog().getByText(/S01E03/).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Platz-Prüfung im Dialog: der Arbeitsordner reicht', async () => {
|
||||||
|
await dialog().getByText(/^Platz: .* frei im Arbeitsordner/).waitFor()
|
||||||
|
})
|
||||||
|
await bild('dialog')
|
||||||
|
|
||||||
|
await schritt('Rip starten → Phase RIPPT, der Automatik-Kasten ist weg', async () => {
|
||||||
|
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
|
||||||
|
await seite.getByText(/RIPPT/).first().waitFor()
|
||||||
|
if ((await seite.getByText('Automatik gestoppt').count()) !== 0) throw new Error('„Automatik gestoppt" steht noch unter dem laufenden Rip')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('5.6.0: Vorgangs-Leiste in der Kachel — TITEL LESEN fertig, RIPPEN aktiv, KOMPRIMIEREN und ABLEGEN offen', async () => {
|
||||||
|
await seite.getByText('TITEL LESEN', { exact: true }).first().waitFor()
|
||||||
|
await seite.getByText('RIPPEN', { exact: true }).first().waitFor()
|
||||||
|
await seite.getByText('KOMPRIMIEREN', { exact: true }).first().waitFor()
|
||||||
|
await seite.getByText('ABLEGEN', { exact: true }).first().waitFor()
|
||||||
|
if ((await seite.getByText(/PHASE [0-9]\/4/).count()) !== 0) throw new Error('alte PHASE-x/4-Texte sind noch da')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
|
||||||
|
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
|
||||||
|
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Kompressions-Karte läuft mit Restzeit (ETA aus HandBrakes Zeile), die Kachel zeigt nur eine Kurzzeile', async () => {
|
||||||
|
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
|
||||||
|
await seite.getByText(/^noch /).first().waitFor({ timeout: 20_000 })
|
||||||
|
// Die Leiste in der Karte: zwei Stationen fertig (✓), KOMPRIMIEREN aktiv.
|
||||||
|
if ((await seite.getByText('KOMPRIMIEREN', { exact: true }).count()) < 1) throw new Error('keine Leiste in der Kompressions-Karte')
|
||||||
|
if ((await seite.getByText(/KOMPRESSION LÄUFT/).count()) !== 0) throw new Error('Kachel zeigt noch den vollen Kompressions-Block')
|
||||||
|
await seite.getByText(/Kompression fertig — das Ergebnis steht im Verlauf/).waitFor({ timeout: 60_000 })
|
||||||
|
})
|
||||||
|
await bild('fertig')
|
||||||
|
|
||||||
|
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
|
||||||
|
const serien = join(ablage, 'Serien')
|
||||||
|
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
|
||||||
|
const gefunden = []
|
||||||
|
const gehen = (o) => {
|
||||||
|
for (const e of readdirSync(o, { withFileTypes: true })) {
|
||||||
|
const p = join(o, e.name)
|
||||||
|
if (e.isDirectory()) gehen(p)
|
||||||
|
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gehen(serien)
|
||||||
|
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
|
||||||
|
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
|
||||||
|
throw new Error(`gefunden: ${namen.join(', ')}`)
|
||||||
|
}
|
||||||
|
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
|
||||||
|
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Arbeitsordner: Roh liegt lokal, der Encode-Zwischenordner ist aufgeräumt', async () => {
|
||||||
|
const roh = join(arbeit, 'roh')
|
||||||
|
if (!existsSync(roh) || readdirSync(roh).length === 0) throw new Error(`kein Roh-Ordner unter ${roh}`)
|
||||||
|
const fertig = join(arbeit, 'fertig')
|
||||||
|
if (existsSync(fertig) && readdirSync(fertig).length > 0) throw new Error(`Zwischenordner nicht geräumt: ${readdirSync(fertig).join(', ')}`)
|
||||||
|
const rohInAblage = join(ablage, 'roh')
|
||||||
|
if (existsSync(rohInAblage)) throw new Error('Roh-Ordner liegt in der Ablage statt im Arbeitsordner')
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Verlauf in der Übersicht nennt den Vorgang als fertig', async () => {
|
||||||
|
await seite.getByText('VERLAUF', { exact: true }).waitFor()
|
||||||
|
await seite.getByText(/die letzten 1 Vorgänge/).waitFor()
|
||||||
|
await seite.getByRole('button', { name: 'Ordner öffnen' }).first().waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Bilanz zählt eine Disc', async () => {
|
||||||
|
await seite.getByText('DISCS', { exact: true }).waitFor()
|
||||||
|
await seite.getByText('1 fertig', { exact: true }).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
|
||||||
|
await seite.getByRole('button', { name: 'Einstellungen' }).click()
|
||||||
|
await seite.locator('#roh').waitFor()
|
||||||
|
await seite.getByText(/Belegung jetzt/).waitFor()
|
||||||
|
await seite.getByText(/Roh: \d/).first().waitFor()
|
||||||
|
})
|
||||||
|
await bild('roh-dateien')
|
||||||
|
|
||||||
|
await schritt('Kompression: kein Allgemeines Preset, Sprachen als Chips (Deutsch, Englisch)', async () => {
|
||||||
|
if ((await seite.getByText(/Allgemeines Preset/).count()) !== 0) throw new Error('Allgemeines Preset ist noch da')
|
||||||
|
await seite.locator('#kompression').getByText('Deutsch', { exact: true }).first().waitFor()
|
||||||
|
await seite.locator('#kompression').getByText('Englisch', { exact: true }).first().waitFor()
|
||||||
|
await seite.locator('#kompression').locator('select').first().waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Karten, MakeMKV-Karte trägt den Schlüssel', async () => {
|
||||||
|
const n = await seite.locator('#werkzeuge').getByRole('button', { name: /Durchsuchen/ }).count()
|
||||||
|
if (n < 3) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
|
||||||
|
await seite.locator('#allgemein').waitFor()
|
||||||
|
await seite.locator('#update').waitFor()
|
||||||
|
await seite.locator('#automatik').waitFor()
|
||||||
|
await seite.locator('#benachrichtigung').waitFor()
|
||||||
|
await seite.locator('#makemkv').getByText(/Eigener Schlüssel/).waitFor()
|
||||||
|
await seite.getByRole('button', { name: 'Protokoll öffnen' }).waitFor()
|
||||||
|
})
|
||||||
|
await bild('einstellungen')
|
||||||
|
|
||||||
|
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
|
||||||
|
await seite.getByRole('button', { name: 'Roh-Dateien löschen …' }).first().click()
|
||||||
|
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
|
||||||
|
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
||||||
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
|
||||||
|
await seite.locator('html[data-design="hell"]').waitFor()
|
||||||
|
})
|
||||||
|
await bild('hell-modus')
|
||||||
|
|
||||||
|
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||||
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
|
||||||
|
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('5.7.0: „Titel lesen — ohne Lebenszeichen höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||||
|
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — ohne Lebenszeichen höchstens')
|
||||||
|
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
|
||||||
|
await regler.selectOption('30')
|
||||||
|
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
|
||||||
|
// zeigt das (kontrollierte) Feld 30.
|
||||||
|
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||||
|
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||||
|
await seite.getByText(/Kompression „/).first().waitFor()
|
||||||
|
})
|
||||||
|
await bild('ereignisse')
|
||||||
|
|
||||||
|
// ── Zweiter Start: die Erst-Einrichtung (5.5.0) ─────────────────────────
|
||||||
|
// RIPPY_START_BEREICH=einrichtung erzwingt den Assistenten (#einrichtung),
|
||||||
|
// auch wenn setup.done schon gesetzt ist — derselbe Weg wie im Smoke.
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
app = await start({ RIPPY_START_BEREICH: 'einrichtung' })
|
||||||
|
seite = await fenster()
|
||||||
|
const weiter = () => seite.getByRole('button', { name: 'Weiter', exact: true }).click()
|
||||||
|
|
||||||
|
await schritt('Erst-Einrichtung: Willkommen vor der Poster-Kulisse (7 Spalten Platzhalter)', async () => {
|
||||||
|
await seite.getByText('Willkommen bei Rippy').waitFor()
|
||||||
|
const spalten = await seite.locator('.poster-spalte').count()
|
||||||
|
if (spalten !== 7) throw new Error(`${spalten} Poster-Spalten`)
|
||||||
|
})
|
||||||
|
await bild('einrichtung-willkommen')
|
||||||
|
|
||||||
|
await schritt('Prüfung: MakeMKV (Nachbau) gefunden, HandBrake dabei, Laufwerk Q: gesehen', async () => {
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText(/MakeMKV gefunden/).waitFor()
|
||||||
|
await seite.getByText(/HandBrakeCLI dabei/).waitFor()
|
||||||
|
await seite.getByText(/1 optisches Laufwerk gefunden/).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Ordner: Ablage und Arbeitsordner mit Durchsuchen und freiem Platz', async () => {
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText('Ablage-Ordner', { exact: true }).waitFor()
|
||||||
|
await seite.getByText('Arbeitsordner (lokal, optional)').waitFor()
|
||||||
|
const n = await seite.getByRole('button', { name: /Durchsuchen/ }).count()
|
||||||
|
if (n < 2) throw new Error(`nur ${n} Durchsuchen-Knöpfe`)
|
||||||
|
await seite.getByText(/frei von/).first().waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Qualität: gemessene Encoder, Empfehlung je Disc-Typ, Sprach-Chips', async () => {
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText('AUF DIESER MASCHINE GEMESSEN:').waitFor()
|
||||||
|
await seite.getByText('4K-UHD', { exact: true }).waitFor()
|
||||||
|
await seite.getByText('Deutsch', { exact: true }).first().waitFor()
|
||||||
|
})
|
||||||
|
await bild('einrichtung-qualitaet')
|
||||||
|
|
||||||
|
await schritt('Titel, Meldungen, Automatik: TMDb-Schlüssel, Discord/Telegram mit Testknopf, Countdown und Roh-Regel', async () => {
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText('TMDb-API-Key', { exact: true }).waitFor()
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText('Discord-Webhook', { exact: true }).waitFor()
|
||||||
|
await seite.getByRole('button', { name: 'Testnachricht schicken' }).waitFor()
|
||||||
|
await weiter()
|
||||||
|
await seite.getByText('Countdown', { exact: true }).waitFor()
|
||||||
|
await seite.getByText(/Roh-Dateien nach gelungener Kompression/).waitFor()
|
||||||
|
})
|
||||||
|
|
||||||
|
await schritt('Fertig: der Knopf Los geht’s', async () => {
|
||||||
|
await weiter()
|
||||||
|
await seite.getByRole('button', { name: "Los geht's" }).waitFor()
|
||||||
|
})
|
||||||
|
await bild('einrichtung-fertig')
|
||||||
|
|
||||||
|
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
process.exit(0)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||||
|
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
|
||||||
|
// HandBrake 1.11 gemessen haben:
|
||||||
|
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
|
||||||
|
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
|
||||||
|
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
|
||||||
|
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert;
|
||||||
|
// ab der zweiten Stufe mit der fps/ETA-Klammer, wie das
|
||||||
|
// Binary sie formatiert (5.6.0, Restzeit).
|
||||||
|
const fs = require('node:fs')
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||||
|
const wert = (schalter) => {
|
||||||
|
const i = args.indexOf(schalter)
|
||||||
|
return i >= 0 ? args[i + 1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
|
||||||
|
out('')
|
||||||
|
out('Video Options -----------------------------------------------------------------')
|
||||||
|
out('')
|
||||||
|
out(' -e, --encoder <string> Select video encoder:')
|
||||||
|
out(' x264')
|
||||||
|
out(' x265')
|
||||||
|
out(' nvenc_h264')
|
||||||
|
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
|
||||||
|
out('')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--preset-list')) {
|
||||||
|
out('General/')
|
||||||
|
out(' Very Fast 1080p30')
|
||||||
|
out(' Fast 1080p30')
|
||||||
|
out(' HQ 1080p30 Surround')
|
||||||
|
out('')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.includes('--scan')) {
|
||||||
|
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
|
||||||
|
out('[12:00:00] hb_init: starting libhb thread')
|
||||||
|
out('Scanning title 1 of 1, preview 1, 100.00 %')
|
||||||
|
out('JSON Title Set: {')
|
||||||
|
out('"MainFeature": 0,')
|
||||||
|
out('"TitleList": [')
|
||||||
|
out('{')
|
||||||
|
// 5.7.0: CodecName und ChannelCount wie das echte 1.11.2 (gemessen
|
||||||
|
// 12.09.2026) — dieselben Spuren, die fake-makemkvcon meldet, ohne den
|
||||||
|
// DTS-Kern (den nimmt MakeMKVs Regel nicht mit).
|
||||||
|
out('"AudioList": [')
|
||||||
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||||
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },')
|
||||||
|
out('{ "ChannelCount": 2, "ChannelLayout": 3, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 3 }')
|
||||||
|
out('],')
|
||||||
|
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||||
|
out('"Index": 1,')
|
||||||
|
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||||
|
out('"SubtitleList": [')
|
||||||
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
|
||||||
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },')
|
||||||
|
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }')
|
||||||
|
out(']')
|
||||||
|
out('}')
|
||||||
|
out(']')
|
||||||
|
out('}')
|
||||||
|
out('HandBrake has exited.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = wert('--input') ?? wert('-i')
|
||||||
|
const ausgabe = wert('--output') ?? wert('-o')
|
||||||
|
if (eingabe === undefined || ausgabe === undefined) {
|
||||||
|
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
out('[12:00:00] hb_init: starting libhb thread')
|
||||||
|
out('Scanning title 1 of 1, preview 10, 100.00 %')
|
||||||
|
let schritt = 0
|
||||||
|
const stufen = [5, 30, 55, 80, 100]
|
||||||
|
const tick = () => {
|
||||||
|
const prozent = stufen[schritt].toFixed(2)
|
||||||
|
if (schritt === 0) out(`Encoding: task 1 of 1, ${prozent} %`)
|
||||||
|
else {
|
||||||
|
// Formatstring aus dem Binary (1.11.2): „%.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)"
|
||||||
|
const rest = (stufen.length - 1 - schritt) * 300
|
||||||
|
out(`Encoding: task 1 of 1, ${prozent} % (48.00 fps, avg 47.50 fps, ETA 00h${String(Math.floor(rest / 60)).padStart(2, '0')}m${String(rest % 60).padStart(2, '0')}s)`)
|
||||||
|
}
|
||||||
|
schritt += 1
|
||||||
|
if (schritt < stufen.length) {
|
||||||
|
setTimeout(tick, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.copyFileSync(eingabe, ausgabe)
|
||||||
|
out('[12:00:05] libhb: work result = 0')
|
||||||
|
out('')
|
||||||
|
out('Encode done!')
|
||||||
|
out('HandBrake has exited.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
setTimeout(tick, 200)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||||
|
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
|
||||||
|
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
|
||||||
|
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
|
||||||
|
//
|
||||||
|
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
|
||||||
|
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
|
||||||
|
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
|
||||||
|
//
|
||||||
|
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const befehl = args.find((a) => a === 'info' || a === 'mkv')
|
||||||
|
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||||
|
|
||||||
|
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
|
||||||
|
|
||||||
|
const TITEL = [
|
||||||
|
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
|
||||||
|
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
|
||||||
|
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (befehl === 'info') {
|
||||||
|
out(`TCOUNT:${TITEL.length}`)
|
||||||
|
for (const t of TITEL) {
|
||||||
|
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
|
||||||
|
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
|
||||||
|
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||||
|
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||||
|
if (t.nr < 2) {
|
||||||
|
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
|
||||||
|
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
|
||||||
|
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
|
||||||
|
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
|
||||||
|
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
|
||||||
|
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
|
||||||
|
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
|
||||||
|
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},22,0,"${flags}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},30,0,"${text}"`)
|
||||||
|
}
|
||||||
|
spur(1, 6202, 'Audio', 'jpn', 'Japanese', 'DTS-HD MA', '6', 'Surround 5.1', 1024, 'DTS-HD MA Surround 5.1 Japanese')
|
||||||
|
spur(2, 6202, 'Audio', 'jpn', 'Japanese', 'DTS', '6', 'Surround 5.1', 2304, 'DTS Surround 5.1 Japanese')
|
||||||
|
spur(3, 6202, 'Audio', 'deu', 'German', 'DD', '6', 'Surround 5.1', 0, 'DD Surround 5.1 German')
|
||||||
|
spur(4, 6202, 'Audio', 'deu', 'German', 'DD', '2', 'Stereo', 0, 'DD Stereo German (Kommentar)')
|
||||||
|
spur(5, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 0, 'PGS German')
|
||||||
|
spur(6, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 6144, 'PGS German (nur erzwungene)')
|
||||||
|
spur(7, 6203, 'Untertitel', 'eng', 'English', 'PGS', '', '', 0, 'PGS English')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (befehl === 'mkv') {
|
||||||
|
const i = args.indexOf('mkv')
|
||||||
|
const nr = Number(args[i + 2])
|
||||||
|
const ziel = args[i + 3]
|
||||||
|
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
|
||||||
|
out('MSG:5010,0,0,"Falscher Aufruf"')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
let schritt = 0
|
||||||
|
const stufen = [10, 35, 60, 85, 100]
|
||||||
|
const tick = () => {
|
||||||
|
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
|
||||||
|
schritt += 1
|
||||||
|
if (schritt < stufen.length) {
|
||||||
|
setTimeout(tick, 250)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.mkdirSync(ziel, { recursive: true })
|
||||||
|
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
|
||||||
|
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
|
||||||
|
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
|
||||||
|
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
setTimeout(tick, 200)
|
||||||
|
} else {
|
||||||
|
out('MSG:5010,0,0,"Unbekannter Befehl"')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
Version 5.7.1
|
||||||
|
|
||||||
|
Titel lesen: Rippy wartet, solange MakeMKV arbeitet.
|
||||||
|
"Der Patriot" (DVD mit beschaedigter IFO-Datei) scheiterte in 5.7.0 nach
|
||||||
|
15 Minuten, waehrend das MakeMKV-Programm die Disc fertig las: MakeMKV
|
||||||
|
liest bei so einer Disc die ganze VOB-Datei durch, und das dauert so lange
|
||||||
|
wie das Lesen der Disc. Die Grenze aus den Einstellungen gilt jetzt fuer
|
||||||
|
Stille - solange MakeMKV Fortschritt oder Meldungen schickt, wartet Rippy
|
||||||
|
(kuendigt MakeMKV den VOB-Scan an, bis zu vier Stunden). Der Dialog zeigt
|
||||||
|
dabei, was MakeMKV gerade tut: Schritt, Prozent und letzte Meldung.
|
||||||
|
"Abbrechen" beendet den Lauf jederzeit. Bricht Rippy doch ab, stehen die
|
||||||
|
letzten Zeilen von MakeMKV im Protokoll.
|
||||||
|
|
||||||
|
Version 5.7.0
|
||||||
|
|
||||||
|
Der Beta-Schluessel kommt ganz an.
|
||||||
|
Der aktuelle MakeMKV-Beta-Schluessel enthaelt ein "@" - Rippy nahm nur
|
||||||
|
62 der 68 Zeichen, MakeMKV lief danach ohne Schluessel, und Blu-ray-Rips
|
||||||
|
scheiterten nach der Gnadenfrist. Das ist die wahrscheinlichste Ursache
|
||||||
|
fuer "Der Patriot", "Deja Vu" und "Training Day" auf dem anderen PC. Die
|
||||||
|
Schluessel-Karte sagt jetzt ehrlich, was Sache ist: gruen (angenommen),
|
||||||
|
grau (nicht pruefbar - ohne eingelegte Disc gibt MakeMKV keine Auskunft),
|
||||||
|
rot (abgelehnt - Blu-rays laufen dann nur noch in der Gnadenfrist). Ein
|
||||||
|
vorhandener, angenommener Schluessel wird nie ueberschrieben.
|
||||||
|
|
||||||
|
Jede Spur einzeln anhaken.
|
||||||
|
Der Auswahl-Dialog zeigt je Titel jede Tonspur mit Codec und Kanaelen
|
||||||
|
("German - DTS-HD MA - Surround 5.1", "German - DD - Stereo (Kommentar)")
|
||||||
|
und jede Untertitel-Spur ("German - PGS", "German - PGS (nur
|
||||||
|
erzwungene)"). Vorbelegt wie bisher - je Wunschsprache die erste Tonspur,
|
||||||
|
alle Untertitel der Wunschsprachen - aber angehakt wird einzeln. Die
|
||||||
|
Untertitel-Vorwahl bei Extras ist jetzt eine Spur. MakeMKV nimmt dafuer
|
||||||
|
auch Stereo-Fassungen und Kommentare in die Roh-Datei (nur die
|
||||||
|
abgeleiteten DTS-Kerne nicht). Was in der Roh-Datei fehlt, steht im
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|
Ton bleibt verlustfrei.
|
||||||
|
HandBrake hat seit 5.0 jede Tonspur in AAC umgerechnet - die Kopiermaske
|
||||||
|
war nicht ausdruecklich gesetzt, und die Kommandozeile verengt sie dann
|
||||||
|
auf AAC. Jetzt werden AC3, E-AC3, DTS, DTS-HD, TrueHD, FLAC, MP3 und AAC
|
||||||
|
durchgereicht. Filme, bei denen der HD-Ton wichtig ist und deren
|
||||||
|
Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
Titel lesen bis 15 Minuten - einstellbar.
|
||||||
|
Einstellungen -> Allgemein -> "Titel lesen - hoechstens": 5 bis 60
|
||||||
|
Minuten, Vorgabe 15 (vorher fest 5). Ab der zweiten Minute steht im
|
||||||
|
Dialog, wie lange MakeMKV schon liest; "Abbrechen" beendet den Lauf auch
|
||||||
|
im Kern, das Laufwerk wird frei. Laeuft die Zeit ab, nennt die Meldung
|
||||||
|
Kopierschutz und viele Playlisten als moegliche Gruende.
|
||||||
|
|
||||||
|
4K-UHD nach der Leistung des PCs.
|
||||||
|
Ist eine Grafikkarte mit 4K-Preset da (VCN, NVENC, QSV), wird 4K damit
|
||||||
|
komprimiert (8 Bit); nur mit CPU bleibt der Rip verlustfrei, statt
|
||||||
|
tagelang auf 1080p zu rechnen. Eigene Wahl schlaegt beides. AV1 10 Bit
|
||||||
|
steht als eigene Wahl in der Liste (nur fuer AV1-faehige Abspielgeraete).
|
||||||
|
|
||||||
|
Disc-Typ aus dem Dateisystem.
|
||||||
|
BDMV/index.bdmv mit "INDX0300" ist eine 4K-UHD, "0200"/"0100" eine
|
||||||
|
Blu-ray, VIDEO_TS eine DVD - auch wenn der Windows-Steuercode "unbekannt"
|
||||||
|
sagt. Vorher wurde eine 4K-Disc mit 48 GB als Blu-ray behandelt
|
||||||
|
(falsches Preset) und "unbekannt" direkt zum Rettungs-Abbild.
|
||||||
|
|
||||||
|
Kein stilles Scheitern mehr.
|
||||||
|
Der Rip-Start prueft, ob die Ablage schreibbar ist, und sagt es im
|
||||||
|
Fenster, wenn nicht ("NAS verbunden?"); Fehler im Kern landen im
|
||||||
|
Protokoll und im Fenster; nach einem Kern-Neustart bleibt das Fenster
|
||||||
|
nicht mehr taub; jedes Laufwerk hat genau eine Wache; das Rettungs-Abbild
|
||||||
|
ueberlebt das Aufraeumen; ein Titel-Auftrag bei Typ "unbekannt" rippt
|
||||||
|
statt ein ISO zu sichern.
|
||||||
|
|
||||||
|
Serien ueber mehrere Discs.
|
||||||
|
Disc 2 benennt nur ihre eigenen Folgen (S01E03, S01E04) und ueberschreibt
|
||||||
|
nie, was Disc 1 abgelegt hat; eine vorhandene Datei gleichen Namens
|
||||||
|
bekommt "(2)". HandBrake schreibt erst auf einen Zwischennamen - eine
|
||||||
|
halbe Datei liegt nie unter dem endgueltigen Namen in der Ablage.
|
||||||
|
|
||||||
|
Erkennung: Originaltitel und Akzente.
|
||||||
|
"THE_PATRIOT" trifft "Der Patriot", "DEJA_VU" trifft "Deja Vu" - TMDb
|
||||||
|
antwortet auf Deutsch, die Disc traegt den Originaltitel; verglichen
|
||||||
|
werden jetzt beide, Akzente zaehlen nicht.
|
||||||
|
|
||||||
|
MakeMKV-Installer nur nach Rueckfrage.
|
||||||
|
Ist MakeMKV nicht da und die Herstellerseite nicht erreichbar, fragt
|
||||||
|
Rippy, bevor eine Kopie aus dem Internet Archive geladen wird - mit
|
||||||
|
Groesse und SHA-256 im Bericht.
|
||||||
|
|
||||||
|
Dazu: Tray "Beenden" fragt nach, wenn ein Rip oder eine Kompression
|
||||||
|
laeuft; "Roh-Dateien loeschen" und "Ordner loeschen" fragen im Fenster
|
||||||
|
nach; Eingabefelder speichern auch bei Enter und beim Bereichswechsel;
|
||||||
|
Escape schliesst Dialoge; "Disc erkannt" einmal je Disc statt dreimal;
|
||||||
|
Laufwerks-Gesundheit zaehlt nur das eigene Laufwerk, nie eine Festplatte;
|
||||||
|
der Kompressions-Fortschritt belastet NAS und Fenster nicht mehr jede
|
||||||
|
Sekunde; Zeiten in Ortszeit; NFO mit TMDb/IMDb-Kennung; doppelter
|
||||||
|
Zielordner heisst "(2)"; Umlaute in drei Knopftexten; "Neu komprimieren"
|
||||||
|
nur, wo es Roh-Dateien gibt; die Automatik sagt bei Audio-CD und
|
||||||
|
unbekannter Disc, warum sie nichts tut; Audio-CDs lassen sich mitten in
|
||||||
|
der Spur abbrechen.
|
||||||
|
|
||||||
|
Version 5.6.1
|
||||||
|
|
||||||
|
Untertitel-Spuren werden wieder erkannt.
|
||||||
|
MakeMKV schreibt den Typ eines Streams in seiner Anzeigesprache - auf einem
|
||||||
|
deutsch eingestellten MakeMKV "Untertitel" statt "Subtitles". Rippy prueft
|
||||||
|
seit 5.4.0 auf das englische Wort und fand deshalb keine Untertitel mehr
|
||||||
|
(Ton heisst in beiden Sprachen "Audio", darum fiel es dort nicht auf).
|
||||||
|
Jetzt zaehlt der Typ-Code (6202 Ton, 6203 Untertitel), der ist
|
||||||
|
sprachunabhaengig. Gemessen an der Blu-ray "Digimon Adventure: Last
|
||||||
|
Evolution Kizuna": drei deutsche PGS-Spuren, darunter eine nur fuer
|
||||||
|
erzwungene Untertitel.
|
||||||
|
|
||||||
|
Version 5.6.0
|
||||||
|
|
||||||
|
Mehrere Filme auf einer Disc.
|
||||||
|
Im Auswahl-Dialog bekommt jeder Titel "anderer Film ..." mit TMDb-Suche
|
||||||
|
(Double Feature, Trilogie-Disc, Bonus-Film). Jeder Film erhaelt seinen
|
||||||
|
eigenen Roh-Ordner, Vorgang und Zielordner mit Poster und Infodatei; der
|
||||||
|
Verlauf zeigt beide.
|
||||||
|
|
||||||
|
Ordner und Dateien tragen den Filmnamen.
|
||||||
|
Der Roh-Ordner heisst wie der Film ("Akira (1988)", bei Wiederholung mit
|
||||||
|
Zusatz), die Dateien auch: "Akira (1988).mkv", zwei Fassungen "Akira (1988)
|
||||||
|
- Fassung 1.mkv", Zugaben "Akira (1988) - Extra 01.mkv" im Unterordner
|
||||||
|
extras. Folgen behalten ihren Namen, bis die Episoden-Zuordnung sie
|
||||||
|
benennt; unerkannte Discs behalten den Zeitstempel-Namen.
|
||||||
|
|
||||||
|
Untertitel-Vorwahl je Extra.
|
||||||
|
Bei Zugaben (z. B. japanisches Bonus-Material) legst du im Dialog fest,
|
||||||
|
welche Untertitel-Spur beim Abspielen schon an ist - als weiche Spur,
|
||||||
|
nichts wird eingebrannt, immer optional. Rippys Regel (fremder Ton ->
|
||||||
|
Wunschsprache) bleibt die Vorgabe.
|
||||||
|
|
||||||
|
Eine Vorgangs-Leiste statt "Phase 1-4 plus Komprimierung".
|
||||||
|
Jede Disc hat EINEN Weg mit vier Stationen: Titel lesen, Rippen,
|
||||||
|
Komprimieren, Ablegen (das Rettungs-Abbild schiebt sich bei Bedarf davor).
|
||||||
|
Die Leiste zeigt immer den ganzen Weg, der Segment-Balken sitzt in der
|
||||||
|
aktiven Station: in der Laufwerks-Kachel, solange das Laufwerk arbeitet,
|
||||||
|
danach in der Kompressions-Karte mit HandBrakes eigener Restzeit ("noch
|
||||||
|
~12 min"), im Verlauf als vier Punkte. Die Kachel zeigt nach dem Rip nur
|
||||||
|
noch eine Zeile, wo es weitergeht.
|
||||||
|
|
||||||
|
Hell-Modus.
|
||||||
|
Einstellungen -> Allgemein -> Erscheinungsbild: Dunkel (der Kinosaal),
|
||||||
|
Hell (Papier und Gold) oder wie Windows.
|
||||||
|
|
||||||
|
Dazu: Die Kopfzeile mit Minimieren, Maximieren und Schliessen bleibt beim
|
||||||
|
Scrollen stehen.
|
||||||
|
|
||||||
|
Version 5.5.0
|
||||||
|
|
||||||
|
Uebersicht und Einstellungen neu sortiert.
|
||||||
|
Die Bibliothek ist weg (Jellyfin IST die Bibliothek). Dafuer zeigt die
|
||||||
|
Uebersicht den Verlauf der letzten Vorgaenge, den freien Platz auf
|
||||||
|
Arbeitsordner und Ablage samt Bilanz (Discs, gesicherte GB, Dauer je Disc)
|
||||||
|
und eine Fussleiste mit Kern, Datenbank, Werkzeugen, Schluessel, freiem
|
||||||
|
Platz und Update-Stand. Der MakeMKV-Kopf ist in die Einstellungen gezogen.
|
||||||
|
Dort gibt es eigene Karten fuer Automatik, Benachrichtigung, Allgemein und
|
||||||
|
Update; "Allgemeines Preset" ist weg, Ton- und Untertitelsprachen sind
|
||||||
|
Auswahl-Chips mit Dropdown in Wunschreihenfolge; Werkzeuge haben
|
||||||
|
"Durchsuchen ...".
|
||||||
|
|
||||||
|
Arbeitsordner lokal, Ablage auf dem Server.
|
||||||
|
Roh-Dateien und Kompression liegen im Arbeitsordner (lokale Platte), nur
|
||||||
|
das fertige Ergebnis wandert in die Ablage. Vor jedem Rip prueft Rippy den
|
||||||
|
Platz: zu wenig fuer den verlustfreien Rip heisst kein Start, knapp heisst
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|
Automatik mit Countdown.
|
||||||
|
Ist Rippy sicher (ab 90 %) und die Disc noch nicht gerippt, liest es die
|
||||||
|
Titel und startet nach 30 s (einstellbar) mit seinem Vorschlag - mit
|
||||||
|
"Stopp" und "Jetzt" in der Kachel. Bei Unsicherheit wartet es und sagt
|
||||||
|
warum.
|
||||||
|
|
||||||
|
Serien-Merker ueber Discs hinweg.
|
||||||
|
Disc 1 endet mit Folge 4, Disc 2 zaehlt ab 5 weiter - der Dialog schlaegt
|
||||||
|
es vor, aenderbar.
|
||||||
|
|
||||||
|
Rettungs-Abbild fuer defekte Discs.
|
||||||
|
Hat Rippy Lesefehler gesehen (Defekt-Waechter, Lesefehler-Meldung), bietet
|
||||||
|
die Kachel "Rettungs-Abbild" an: Rippys eigener Sektor-Leser liest die Disc
|
||||||
|
Stueck fuer Stueck, wiederholt in immer kleineren Stuecken und fuellt nur
|
||||||
|
das wirklich Unlesbare mit Nullen; danach laeuft derselbe Rip aus dem
|
||||||
|
Abbild, an dem MakeMKV nicht mehr haengen bleibt. Ein Bericht daneben
|
||||||
|
nennt jede genullte Stelle. Bei einer heilen Disc passiert das nie.
|
||||||
|
|
||||||
|
Warteschlange pausieren, Benachrichtigung, Protokoll.
|
||||||
|
"Pausieren" laesst den laufenden Film fertig rechnen und haelt die Schlange
|
||||||
|
an. Discord-Webhook oder Telegram-Bot melden fertige Rips, Kompressionen
|
||||||
|
und Fehler aufs Handy (Testknopf). Haupt und Kern schreiben eine
|
||||||
|
Protokoll-Datei je Tag (14 Tage), "Protokoll oeffnen" unter Allgemein;
|
||||||
|
Ordner oeffnen sich aus Verlauf, Roh-Dateien und Speicher-Karte im Explorer.
|
||||||
|
|
||||||
|
Erst-Einrichtung mit Poster-Kulisse.
|
||||||
|
Acht Schritte mit denselben Bausteinen wie die Einstellungen (Ordner,
|
||||||
|
Qualitaet mit Sprachen, Titel, Meldungen, Automatik) vor einem langsam
|
||||||
|
laufenden Poster-Raster - Platzhalter bis zum TMDb-Schluessel, danach
|
||||||
|
echte Poster.
|
||||||
|
|
||||||
|
Dazu: Das Fenster oeffnet mit 80 % des Bildschirms, mittig, mindestens
|
||||||
|
1100 x 720, und merkt sich Lage und Groesse (nur, wenn sie noch auf einem
|
||||||
|
Bildschirm liegt). Oberflaeche und Kern sind in Bereiche aufgeteilt.
|
||||||
|
|
||||||
|
Version 5.4.0
|
||||||
|
|
||||||
|
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
|
||||||
|
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
|
||||||
|
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
|
||||||
|
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
|
||||||
|
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
|
||||||
|
|
||||||
|
Roh-Dateien raeumen sich auf.
|
||||||
|
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
|
||||||
|
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
|
||||||
|
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
|
||||||
|
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
|
||||||
|
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
|
||||||
|
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
|
||||||
|
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
|
||||||
|
|
||||||
|
Sprachen je Titel.
|
||||||
|
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
|
||||||
|
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
|
||||||
|
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
|
||||||
|
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
|
||||||
|
dass alle Sprachen mitkommen.
|
||||||
|
|
||||||
|
Staffel und Folge von Hand.
|
||||||
|
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
|
||||||
|
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
|
||||||
|
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
|
||||||
|
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
|
||||||
|
|
||||||
|
Erkennung v2 und Kino-Banner.
|
||||||
|
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
|
||||||
|
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
|
||||||
|
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
|
||||||
|
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
|
||||||
|
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
|
||||||
|
Hardware?
|
||||||
|
|
||||||
|
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
|
||||||
|
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
|
||||||
|
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
|
||||||
|
ganzen Weg durch das echte Programm.
|
||||||
|
|
||||||
|
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
|
||||||
|
|
||||||
|
Version 5.3.2
|
||||||
|
|
||||||
|
Die Titelliste bleibt jetzt stehen.
|
||||||
|
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
|
||||||
|
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
|
||||||
|
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
|
||||||
|
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
|
||||||
|
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
|
||||||
|
auf derselben Disc gegenseitig aus.
|
||||||
|
|
||||||
|
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
|
||||||
|
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
|
||||||
|
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
|
||||||
|
|
||||||
|
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
|
||||||
|
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
|
||||||
|
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
|
||||||
|
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
|
||||||
|
|
||||||
|
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
|
||||||
|
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,70 @@
|
|||||||
|
// 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'. font-src data: (5.4.0, im Klick-Beweis
|
||||||
|
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
|
||||||
|
// als data:-URI ein, und die blockte die CSP — als Fehler in der
|
||||||
|
// Konsole, unsichtbar im Fenster.
|
||||||
|
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,44 @@
|
|||||||
|
{
|
||||||
|
"name": "rippy",
|
||||||
|
"productName": "Rippy",
|
||||||
|
"version": "5.7.1",
|
||||||
|
"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",
|
||||||
|
"klick": "electron-vite build && node bau/klick-smoke.mjs",
|
||||||
|
"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",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
|
"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,86 @@
|
|||||||
|
// 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
|
||||||
|
// … plus die Windows-Dialoge und Shell-Wege, die nur das Haupt öffnen kann
|
||||||
|
// (Ordner/Datei wählen, Ordner im Explorer zeigen, Protokoll öffnen).
|
||||||
|
//
|
||||||
|
// 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>
|
||||||
|
},
|
||||||
|
/** Echter Windows-Dateidialog für Programme (5.5.0: Werkzeuge → Durchsuchen). */
|
||||||
|
dateiWaehlen(titel: string): Promise<string | null> {
|
||||||
|
return ipcRenderer.invoke('datei-waehlen', String(titel)) as Promise<string | null>
|
||||||
|
},
|
||||||
|
/** Einen Ordner im Explorer zeigen (5.5.0). Liefert '' oder den Fehler. */
|
||||||
|
ordnerOeffnen(pfad: string): Promise<string> {
|
||||||
|
return ipcRenderer.invoke('ordner-oeffnen', String(pfad)) as Promise<string>
|
||||||
|
},
|
||||||
|
/** Den Protokoll-Ordner im Explorer zeigen (5.5.0). */
|
||||||
|
protokollOeffnen(): Promise<string> {
|
||||||
|
return ipcRenderer.invoke('protokoll-oeffnen') as Promise<string>
|
||||||
|
},
|
||||||
|
/** 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>
|
||||||
|
},
|
||||||
|
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
|
||||||
|
* „schliessen" versteckt nur — Rippy lebt im Tray weiter. */
|
||||||
|
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
|
||||||
|
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
|
||||||
|
},
|
||||||
|
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
|
||||||
|
updatePruefen(): Promise<unknown> {
|
||||||
|
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
|
||||||
|
},
|
||||||
|
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
|
||||||
|
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
|
||||||
|
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026,
|
||||||
|
// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz,
|
||||||
|
// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die
|
||||||
|
// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit
|
||||||
|
// großer Prozentzahl, Mono-Protokoll, LED-Fußleiste.
|
||||||
|
//
|
||||||
|
// Seit 5.5.0 ist diese Datei nur noch der RAHMEN (Separation of Concerns):
|
||||||
|
// Kopfzeile, Bereichswahl, Fußleiste — die Bereiche selbst liegen in
|
||||||
|
// uebersicht/, einstellungen/, einrichtung/, die Dialoge in dialoge/, das
|
||||||
|
// Gemeinsame in bausteine.tsx. Die Regeln bleiben: EINE Einstellungs-Quelle
|
||||||
|
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4 (Fehler
|
||||||
|
// stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { HauptStatus } from '../gemeinsam/nachrichten'
|
||||||
|
import {
|
||||||
|
FensterKnopf,
|
||||||
|
IconDisc,
|
||||||
|
IconMaximieren,
|
||||||
|
IconMinimieren,
|
||||||
|
IconSchliessen,
|
||||||
|
IconWiederherstellen,
|
||||||
|
LAUFENDE_PHASEN,
|
||||||
|
gbKurz,
|
||||||
|
} from './bausteine'
|
||||||
|
import { ErstEinrichtung } from './einrichtung/ErstEinrichtung'
|
||||||
|
import { Einstellungen } from './einstellungen/Einstellungen'
|
||||||
|
import { LEERE_VERBINDUNG, aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
|
||||||
|
import { SEHR_WENIG_FREI_BYTES, WENIG_FREI_BYTES } from './uebersicht/SpeicherKarte'
|
||||||
|
import { Uebersicht } from './uebersicht/Uebersicht'
|
||||||
|
|
||||||
|
type Bereich = 'uebersicht' | 'einstellungen'
|
||||||
|
|
||||||
|
function istHauptStatus(wert: unknown): wert is HauptStatus {
|
||||||
|
return typeof wert === 'object' && wert !== null && typeof (wert as Record<string, unknown>).version === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine LED der Fußleiste (5.5.0): grün/gelb/rot/grau plus Klartext —
|
||||||
|
* statt PID und Ping-Millisekunden aus dem Gerüst-Beweis. */
|
||||||
|
function Led({ ton, text, titel }: { ton: 'gruen' | 'warn' | 'rot' | 'grau'; text: string; titel?: string }) {
|
||||||
|
const farbe = ton === 'gruen' ? 'bg-gruen' : ton === 'warn' ? 'bg-warn' : ton === 'rot' ? 'bg-rot' : 'bg-tinte/25'
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5" title={titel}>
|
||||||
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${farbe}`} />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||||
|
// #einstellungen als Startanker — der Smoke-Beweis fotografiert damit
|
||||||
|
// auch die anderen Bereiche. `start` springt in den Einstellungen zu
|
||||||
|
// einer Karte („alle unter Roh-Dateien →").
|
||||||
|
const [bereich, setBereich] = useState<{ name: Bereich; start?: string }>(() => {
|
||||||
|
const anker = window.location.hash.replace('#', '')
|
||||||
|
return { name: anker === 'einstellungen' ? 'einstellungen' : 'uebersicht' }
|
||||||
|
})
|
||||||
|
const [verbindung, setVerbindung] = useState<Verbindung>(LEERE_VERBINDUNG)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.rippy.aufStatus((status) => {
|
||||||
|
if (istHauptStatus(status)) setHaupt(status)
|
||||||
|
})
|
||||||
|
verbindungStarten()
|
||||||
|
return aufVerbindung(setVerbindung)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 5.6.0 Hell-Modus: die Einstellung „design" schaltet data-design auf
|
||||||
|
// <html> (stil.css überschreibt dann die Farb-Tokens); „system" folgt
|
||||||
|
// Windows.
|
||||||
|
const design = verbindung.einstellungen['design'] ?? 'dunkel'
|
||||||
|
useEffect(() => {
|
||||||
|
const anwenden = (): void => {
|
||||||
|
const hell = design === 'hell' || (design === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches)
|
||||||
|
document.documentElement.dataset['design'] = hell ? 'hell' : 'dunkel'
|
||||||
|
}
|
||||||
|
anwenden()
|
||||||
|
if (design !== 'system') return
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: light)')
|
||||||
|
mq.addEventListener('change', anwenden)
|
||||||
|
return () => mq.removeEventListener('change', anwenden)
|
||||||
|
}, [design])
|
||||||
|
|
||||||
|
const db = verbindung.kern?.datenbank ?? null
|
||||||
|
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||||
|
|
||||||
|
// R4: Systemfehler stehen als rote Karte in der Übersicht — die
|
||||||
|
// LED-Leiste unten zeigt nur den Daumenwert.
|
||||||
|
const systemFehler: string[] = []
|
||||||
|
if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`)
|
||||||
|
if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`)
|
||||||
|
if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.')
|
||||||
|
|
||||||
|
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||||
|
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||||
|
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH).
|
||||||
|
const setupOffen =
|
||||||
|
window.location.hash === '#einrichtung' || (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
|
||||||
|
|
||||||
|
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
||||||
|
<button
|
||||||
|
onClick={() => setBereich({ name: ziel })}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-[13px] ${
|
||||||
|
bereich.name === ziel ? 'border border-gold/40 bg-gold/15 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Fußleiste (5.5.0): der Daumenwert des Systems in sechs LEDs ─────
|
||||||
|
const w = verbindung.werkzeuge
|
||||||
|
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
||||||
|
const s = verbindung.schluessel
|
||||||
|
// 5.7.0 (F1/F5): abgelehnt oder fehlend = ROT (Blu-rays laufen nicht),
|
||||||
|
// nicht prüfbar (kein Laufwerk) = grau — nie mehr ein gelbes „läuft".
|
||||||
|
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.angenommen === null ? 'grau' : 'rot'
|
||||||
|
const arbeit = verbindung.speicher?.arbeit ?? null
|
||||||
|
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
||||||
|
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen flex-col bg-buehne text-schnee">
|
||||||
|
<div className="buehnenlicht pointer-events-none fixed inset-0" />
|
||||||
|
<div className="filmkorn pointer-events-none fixed inset-0" />
|
||||||
|
|
||||||
|
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
|
||||||
|
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
|
||||||
|
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
|
||||||
|
{/* 5.6.0: Die Kopfzeile bleibt beim Scrollen stehen (Commander) —
|
||||||
|
sticky statt relative, mit Bühnen-Hintergrund und Weichzeichner. */}
|
||||||
|
<header className="ziehbar sticky top-0 z-40 flex-none border-b border-tinte/8 bg-buehne/85 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
|
||||||
|
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
|
||||||
|
<span className="hidden rounded-md border border-tinte/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">v{haupt?.version ?? '…'}</span>
|
||||||
|
{einRipLaeuft && (
|
||||||
|
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
|
||||||
|
REC
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
|
||||||
|
{knopf('uebersicht', 'Übersicht')}
|
||||||
|
{knopf('einstellungen', 'Einstellungen')}
|
||||||
|
</nav>
|
||||||
|
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
|
||||||
|
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
|
||||||
|
<IconMinimieren />
|
||||||
|
</FensterKnopf>
|
||||||
|
<FensterKnopf titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'} aufKlick={() => void window.rippy.fensterSteuern('maximieren')}>
|
||||||
|
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
|
||||||
|
</FensterKnopf>
|
||||||
|
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
|
||||||
|
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
|
||||||
|
<FensterKnopf titel="In den Infobereich (Rippy läuft weiter)" rot aufKlick={() => void window.rippy.fensterSteuern('schliessen')}>
|
||||||
|
<IconSchliessen />
|
||||||
|
</FensterKnopf>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
|
||||||
|
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
|
||||||
|
{!setupOffen && bereich.name === 'uebersicht' && (
|
||||||
|
<Uebersicht verbindung={verbindung} haupt={haupt} systemFehler={systemFehler} aufRohDateien={() => setBereich({ name: 'einstellungen', start: 'roh' })} />
|
||||||
|
)}
|
||||||
|
{!setupOffen && bereich.name === 'einstellungen' && <Einstellungen verbindung={verbindung} haupt={haupt} start={bereich.start} />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="relative flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-tinte/8 px-7 py-3 font-mono text-[10px] tracking-[0.08em] text-nebel">
|
||||||
|
<Led ton={verbindung.kern !== null ? 'gruen' : haupt?.kern.laeuft === true ? 'warn' : 'rot'} text="KERN" titel={verbindung.kern !== null ? `läuft (PID ${verbindung.kern.pid})` : 'wartet'} />
|
||||||
|
<Led ton={db === null ? 'grau' : db.ok ? 'gruen' : 'rot'} text="DATENBANK" titel={db !== null && db.ok ? db.pfad : db?.fehler} />
|
||||||
|
<Led
|
||||||
|
ton={werkzeugTon}
|
||||||
|
text="WERKZEUGE"
|
||||||
|
titel={w === null ? 'werden gesucht' : `makemkvcon ${w.makemkvcon.length > 0 ? 'da' : 'FEHLT'} · HandBrake ${w.handbrake.length > 0 ? 'da' : 'fehlt'}`}
|
||||||
|
/>
|
||||||
|
<Led ton={schluesselTon} text="SCHLÜSSEL" titel={s?.text} />
|
||||||
|
<Led
|
||||||
|
ton={speicherTon}
|
||||||
|
text={arbeit !== null && arbeit.ok ? `FREI ${gbKurz(arbeit.freiBytes)}` : 'SPEICHER'}
|
||||||
|
titel={arbeit === null ? 'wird gemessen' : arbeit.ok ? `${verbindung.speicher?.getrennt === true ? 'Arbeitsordner' : 'Ablage'}: ${arbeit.pfad}` : arbeit.fehler}
|
||||||
|
/>
|
||||||
|
<Led
|
||||||
|
ton={updateTon}
|
||||||
|
text={haupt !== null && haupt.update.neueVersion.length > 0 ? `UPDATE ${haupt.update.neueVersion}` : `v${haupt?.version ?? '…'}${haupt !== null && haupt.update.letzteErfolgreichePruefung.length > 0 ? ' AKTUELL' : ''}`}
|
||||||
|
titel={haupt?.update.text}
|
||||||
|
/>
|
||||||
|
<span className="ml-auto">Rippy v5 · LucyAI · Claude · KrBrZ</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
// Die Bausteine der Oberfläche (5.5.0, Separation of Concerns): alles,
|
||||||
|
// was mehr als ein Bereich braucht — Formatierer, Symbole, Badge, Kacheln,
|
||||||
|
// Segment-Anzeige, Schalter, Felder, Karten. Der Kino-Mix (Design-Entscheid
|
||||||
|
// des Commanders, 31.08.2026) lebt in den Tokens von stil.css; hier sind
|
||||||
|
// nur die Klassen, die ihn tragen.
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { DiscInfoStand, GeraeteInfo, RipStatus, SprachEintrag } from '../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
// ── Formatierer ─────────────────────────────────────────────────────────
|
||||||
|
export function gb(bytes: number): string {
|
||||||
|
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Größen mit passender Einheit: 412 GB, 2,2 TB. */
|
||||||
|
export function gbKurz(bytes: number): string {
|
||||||
|
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1).replace('.', ',')} TB`
|
||||||
|
if (bytes >= 1e9) return `${Math.round(bytes / 1e9)} GB`
|
||||||
|
return `${(bytes / 1e6).toFixed(0)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dauerText(s: number): string {
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
return m >= 60
|
||||||
|
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||||
|
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „1 h 42 min" für Statistik und Verlauf. */
|
||||||
|
export function dauerGrob(s: number): string {
|
||||||
|
const m = Math.round(s / 60)
|
||||||
|
if (m < 60) return `${m} min`
|
||||||
|
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-Zeit → „2026-09-12 21:30" in DEINER Uhrzeit (5.7.0, Durchsicht F12):
|
||||||
|
* vorher wurde der UTC-Text abgeschnitten — 21:30 Uhr stand als 19:30 da. */
|
||||||
|
export function datumKurz(iso: string | null): string {
|
||||||
|
if (iso === null || iso.length < 16) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ')
|
||||||
|
const z = (n: number): string => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
|
||||||
|
* noch keine Angabe. */
|
||||||
|
export function restText(restS: number | undefined): string {
|
||||||
|
if (restS === undefined || restS < 0) return 'Restzeit wird berechnet …'
|
||||||
|
if (restS < 60) return `noch ${Math.max(1, Math.round(restS))} s`
|
||||||
|
if (restS < 3600) return `noch ~${Math.ceil(restS / 60)} min`
|
||||||
|
const h = Math.floor(restS / 3600)
|
||||||
|
const m = Math.round((restS - h * 3600) / 60)
|
||||||
|
return `noch ~${h} h ${String(m).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restzeitText(prozent: number, startMs: number | undefined): string {
|
||||||
|
if (startMs === undefined || prozent <= 2 || prozent >= 100) return ''
|
||||||
|
const verstrichen = (Date.now() - startMs) / 1000
|
||||||
|
const restS = Math.round((verstrichen * (100 - prozent)) / prozent)
|
||||||
|
if (restS < 90) return `REST ~${restS} S`
|
||||||
|
return `REST ~${Math.round(restS / 60)} MIN`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TMDb-Bilder: Größen aus /configuration gemessen (01.09.2026) ────────
|
||||||
|
export function posterUrlKlein(pfad: string): string {
|
||||||
|
return bildUrl(pfad, 'w154')
|
||||||
|
}
|
||||||
|
export function posterUrlGross(pfad: string): string {
|
||||||
|
return bildUrl(pfad, 'w342')
|
||||||
|
}
|
||||||
|
export function backdropUrl(pfad: string): string {
|
||||||
|
return bildUrl(pfad, 'w780')
|
||||||
|
}
|
||||||
|
/** Freigestellter Schriftzug — SVG-Logos zeigt Chromium direkt. */
|
||||||
|
export function logoUrl(pfad: string): string {
|
||||||
|
return bildUrl(pfad, 'w300')
|
||||||
|
}
|
||||||
|
export function profilUrl(pfad: string): string {
|
||||||
|
return bildUrl(pfad, 'w185')
|
||||||
|
}
|
||||||
|
function bildUrl(pfad: string, groesse: string): string {
|
||||||
|
if (pfad.length === 0) return ''
|
||||||
|
if (pfad.startsWith('http')) return pfad
|
||||||
|
return `https://image.tmdb.org/t/p/${groesse}${pfad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „1988 · FSK 16 · 124 Min · Animation · ★ 7,9" — nur, was da ist. */
|
||||||
|
export function metaZeile(info: DiscInfoStand): string {
|
||||||
|
const teile: string[] = []
|
||||||
|
if (info.jahr !== null) teile.push(String(info.jahr))
|
||||||
|
if (info.fsk.length > 0) teile.push(`FSK ${info.fsk}`)
|
||||||
|
if (info.laufzeitMinuten > 0) teile.push(`${info.laufzeitMinuten} Min`)
|
||||||
|
if (info.genres.length > 0) teile.push(info.genres.slice(0, 3).join(' · '))
|
||||||
|
if (info.bewertung > 0) teile.push(`★ ${info.bewertung.toFixed(1).replace('.', ',')}`)
|
||||||
|
return teile.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sicherheitsWort(confidence: number): string {
|
||||||
|
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||||
|
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||||
|
if (confidence >= 0.6) return 'Vorschlag'
|
||||||
|
return 'unsicher'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
|
||||||
|
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
|
||||||
|
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
|
||||||
|
* Disc nichts davon verrät. */
|
||||||
|
export function discHerkunft(info: DiscInfoStand): string {
|
||||||
|
const teile: string[] = []
|
||||||
|
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
|
||||||
|
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
|
||||||
|
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
|
||||||
|
return teile.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorbelegung der Sprachen je Titel: die Einstellung, soweit der Titel
|
||||||
|
* sie hat — sonst alles, was er hat (japanischer Ton statt gar keiner). */
|
||||||
|
export function sprachenVorbelegt(vorhanden: readonly SprachEintrag[], einstellung: string): string[] {
|
||||||
|
const da = vorhanden.map((s) => s.lang)
|
||||||
|
const wunsch = einstellung
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
const treffer = wunsch.filter((w) => da.includes(w))
|
||||||
|
return treffer.length > 0 ? treffer : da
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
||||||
|
cd: 'Audio-CD',
|
||||||
|
dvd: 'DVD',
|
||||||
|
bluray: 'Blu-ray',
|
||||||
|
uhd: '4K-UHD',
|
||||||
|
unknown: 'unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASEN_MONO: Record<RipStatus['phase'], string> = {
|
||||||
|
'liest-info': 'LIEST TITEL-INFO',
|
||||||
|
rettet: 'RETTUNGS-ABBILD — LIEST SEKTOR FÜR SEKTOR',
|
||||||
|
rippt: 'RIPPT (VERLUSTFREI)',
|
||||||
|
komprimiert: 'KOMPRIMIERT',
|
||||||
|
'legt-ab': 'LEGT AB',
|
||||||
|
fertig: 'FERTIG',
|
||||||
|
fehler: 'FEHLER',
|
||||||
|
abgebrochen: 'ABGEBROCHEN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LAUFENDE_PHASEN: readonly string[] = ['liest-info', 'rettet', 'rippt', 'komprimiert', 'legt-ab']
|
||||||
|
|
||||||
|
export const FELD_KLASSE =
|
||||||
|
'mt-1 w-full rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none'
|
||||||
|
|
||||||
|
export const KNOPF_LEISE = 'rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35'
|
||||||
|
export const KNOPF_GOLD =
|
||||||
|
'rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35'
|
||||||
|
|
||||||
|
// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ──
|
||||||
|
export function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) {
|
||||||
|
return (
|
||||||
|
<svg width={groesse} height={groesse} viewBox="0 0 24 24" fill="none" stroke={farbe} strokeWidth="1.6">
|
||||||
|
<circle cx="12" cy="12" r="9"></circle>
|
||||||
|
<circle cx="12" cy="12" r="2.6"></circle>
|
||||||
|
<path d="M12 3v3.4M12 17.6V21"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPlay() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 4.5v15l13-7.5z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconStop() {
|
||||||
|
return (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconEject() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 17h14M12 4l6 9H6z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconStift() {
|
||||||
|
return (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M4 20l4.5-1L20 7.5 16.5 4 5 15.5 4 20z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
|
||||||
|
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
|
||||||
|
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
|
||||||
|
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
|
||||||
|
export const IconMinimieren = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const IconMaximieren = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||||
|
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const IconWiederherstellen = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||||
|
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const IconSchliessen = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export function FensterKnopf({
|
||||||
|
titel,
|
||||||
|
aufKlick,
|
||||||
|
rot = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
aufKlick: () => void
|
||||||
|
rot?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={aufKlick}
|
||||||
|
title={titel}
|
||||||
|
aria-label={titel}
|
||||||
|
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
|
||||||
|
rot ? 'hover:bg-rot hover:text-[#fff]' : 'hover:bg-tinte/10 hover:text-schnee'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kleine Bausteine des Schaltpults ────────────────────────────────────
|
||||||
|
export function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) {
|
||||||
|
const farben = {
|
||||||
|
gruen: 'border-gruen/40 bg-gruen/10 text-gruen',
|
||||||
|
gelb: 'border-warn/40 bg-warn/10 text-warn',
|
||||||
|
blau: 'border-gold/40 bg-gold/10 text-gold-hell',
|
||||||
|
grau: 'border-tinte/15 bg-tinte/5 text-dunst',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] leading-4 ${farben[ton]}`}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */
|
||||||
|
export function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-tinte/10 bg-samt/70 px-3 py-2 min-w-0">
|
||||||
|
<div className="font-mono text-[9px] tracking-[0.14em] text-nebel">{label}</div>
|
||||||
|
<div className={`mt-0.5 truncate text-[13px] font-semibold ${farbe ?? 'text-schnee'}`} title={wert}>
|
||||||
|
{wert}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */
|
||||||
|
export function SegmentBalken({ prozent }: { prozent: number }) {
|
||||||
|
const segmente = 20
|
||||||
|
const anteil = Math.max(0, Math.min(100, prozent)) / 100
|
||||||
|
const voll = Math.floor(anteil * segmente)
|
||||||
|
const halb = anteil * segmente - voll >= 0.3
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: segmente }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-3.5 flex-1 rounded-[3px] ${
|
||||||
|
i < voll ? 'segment-gluehen' : i === voll && halb ? 'bg-gold/45' : 'bg-tinte/8'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein schmaler Füllbalken (Speicher): Anteil belegt, Farbe nach Lage. */
|
||||||
|
export function FuellBalken({ anteil, ton }: { anteil: number; ton: 'gold' | 'warn' | 'rot' }) {
|
||||||
|
const farbe = ton === 'gold' ? 'from-gold-tief to-gold-hell' : ton === 'warn' ? 'from-warn to-warn' : 'from-rot to-rot'
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-tinte/8">
|
||||||
|
<div className={`h-full rounded-full bg-gradient-to-r ${farbe} transition-all`} style={{ width: `${Math.max(0, Math.min(100, anteil * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Kicker({ text }: { text: string }) {
|
||||||
|
return <h2 className="font-mono text-[10px] font-semibold tracking-[0.22em] text-gold/80">{text}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein richtiger Schalter statt einer nackten Checkbox. */
|
||||||
|
export function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => aufWechsel(!an)} className="flex items-center gap-3 text-left" role="switch" aria-checked={an}>
|
||||||
|
<span
|
||||||
|
className={`relative inline-block h-5 w-9 flex-none rounded-full transition-colors ${
|
||||||
|
an ? 'bg-gradient-to-b from-gold-hell to-gold-tief' : 'bg-tinte/12'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-schnee shadow transition-all ${an ? 'left-[18px]' : 'left-0.5'}`} />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-5 text-dunst">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */
|
||||||
|
export function SektionsKarte({
|
||||||
|
id,
|
||||||
|
titel,
|
||||||
|
intro,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
intro: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-4 rounded-2xl border border-tinte/10 bg-karte/50 p-5">
|
||||||
|
<Kicker text={titel} />
|
||||||
|
<p className="mt-1.5 max-w-xl text-xs leading-5 text-nebel">{intro}</p>
|
||||||
|
<div className="mt-4 space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */
|
||||||
|
export function HinterlegtPunkt({ wert }: { wert: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`ml-2 font-mono text-[10px] ${wert.length > 0 ? 'text-gruen' : 'text-nebel'}`}>
|
||||||
|
{wert.length > 0 ? '● hinterlegt' : '○ fehlt'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Feld({
|
||||||
|
name,
|
||||||
|
wert,
|
||||||
|
hinweis,
|
||||||
|
geheim,
|
||||||
|
platzhalter,
|
||||||
|
aufAendern,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
wert: string
|
||||||
|
hinweis?: string
|
||||||
|
geheim?: boolean
|
||||||
|
platzhalter?: string
|
||||||
|
aufAendern: (wert: string) => void
|
||||||
|
}) {
|
||||||
|
const [entwurf, setEntwurf] = useState(wert)
|
||||||
|
useEffect(() => setEntwurf(wert), [wert])
|
||||||
|
// 5.7.0 (Durchsicht F18): Gespeichert wird beim Verlassen, bei Enter UND
|
||||||
|
// wenn das Feld verschwindet (Bereichswechsel, Dialog zu) — vorher ging
|
||||||
|
// Tipparbeit verloren, wenn man statt zu klicken Enter drückte oder in
|
||||||
|
// einen anderen Bereich wechselte. Die Refs tragen den jüngsten Stand in
|
||||||
|
// die Aufräum-Funktion des Effekts.
|
||||||
|
const juengster = useRef({ entwurf, wert, aufAendern })
|
||||||
|
juengster.current = { entwurf, wert, aufAendern }
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
const j = juengster.current
|
||||||
|
if (j.entwurf !== j.wert) j.aufAendern(j.entwurf)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const speichern = (): void => {
|
||||||
|
if (entwurf !== wert) aufAendern(entwurf)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||||
|
{hinweis !== undefined && <span className="ml-2 text-xs text-nebel">{hinweis}</span>}
|
||||||
|
<input
|
||||||
|
type={geheim === true ? 'password' : 'text'}
|
||||||
|
value={entwurf}
|
||||||
|
placeholder={platzhalter}
|
||||||
|
onChange={(e) => setEntwurf(e.target.value)}
|
||||||
|
onBlur={speichern}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') speichern()
|
||||||
|
}}
|
||||||
|
className={FELD_KLASSE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Pfad-Feld mit Durchsuchen (Ordner oder Programm) — die echten
|
||||||
|
* Windows-Dialoge, keine nachgebauten (§ 7). */
|
||||||
|
export function PfadFeld({
|
||||||
|
name,
|
||||||
|
wert,
|
||||||
|
hinweis,
|
||||||
|
art,
|
||||||
|
dialogTitel,
|
||||||
|
aufAendern,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
wert: string
|
||||||
|
hinweis?: string
|
||||||
|
art: 'ordner' | 'programm'
|
||||||
|
dialogTitel: string
|
||||||
|
aufAendern: (wert: string) => void
|
||||||
|
}) {
|
||||||
|
const waehlen = async (): Promise<void> => {
|
||||||
|
const gewaehlt = art === 'ordner' ? await window.rippy.ordnerWaehlen(dialogTitel) : await window.rippy.dateiWaehlen(dialogTitel)
|
||||||
|
if (gewaehlt !== null) aufAendern(gewaehlt)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Feld name={name} wert={wert} hinweis={hinweis} aufAendern={aufAendern} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => void waehlen()} className={`${KNOPF_LEISE} py-2`}>
|
||||||
|
Durchsuchen …
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
|
||||||
|
* einen Dialog.
|
||||||
|
*
|
||||||
|
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
|
||||||
|
* selbst den titel sucht das suchfenster schließt sich dann einfach."
|
||||||
|
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
|
||||||
|
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
|
||||||
|
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
|
||||||
|
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
|
||||||
|
* schließt den Dialog also mitten im Tippen.
|
||||||
|
*
|
||||||
|
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
|
||||||
|
* Fläche selbst passierten, ist es ein Klick daneben. */
|
||||||
|
export function useHintergrundKlick(schliessen: () => void): {
|
||||||
|
onMouseDown: (e: React.MouseEvent) => void
|
||||||
|
onClick: (e: React.MouseEvent) => void
|
||||||
|
} {
|
||||||
|
const begannDraussen = useRef(false)
|
||||||
|
return {
|
||||||
|
onMouseDown: (e) => {
|
||||||
|
begannDraussen.current = e.target === e.currentTarget
|
||||||
|
},
|
||||||
|
onClick: (e) => {
|
||||||
|
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Rahmen jedes Dialogs: abgedunkelte Bühne, Karte in der Mitte. */
|
||||||
|
export function DialogRahmen({
|
||||||
|
schliessen,
|
||||||
|
breite,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
schliessen: () => void
|
||||||
|
breite: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const hintergrund = useHintergrundKlick(schliessen)
|
||||||
|
// 5.7.0 (Durchsicht F19): Escape schließt, der Dialog ist als solcher
|
||||||
|
// ausgezeichnet (role/aria-modal) und bekommt den Fokus — ohne das blieb
|
||||||
|
// die Tastatur auf der Seite dahinter, und Escape tat nichts.
|
||||||
|
const karte = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const taste = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') schliessen()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
// Fokus in den Dialog — aber nicht aus einem Eingabefeld heraus, das
|
||||||
|
// ein Kind schon per autoFocus genommen hat.
|
||||||
|
if (document.activeElement === null || document.activeElement === document.body) karte.current?.focus()
|
||||||
|
return () => window.removeEventListener('keydown', taste)
|
||||||
|
}, [schliessen])
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
||||||
|
<div
|
||||||
|
ref={karte}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60 focus:outline-none`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
|
||||||
|
import { DialogRahmen, IconDisc, posterUrlKlein } from '../bausteine'
|
||||||
|
import { metadatenSuchen, zuordnungSetzen } from '../kernverbindung'
|
||||||
|
|
||||||
|
export function AendernDialog({
|
||||||
|
id,
|
||||||
|
startText,
|
||||||
|
vorschlaege,
|
||||||
|
schliessen,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
startText: string
|
||||||
|
vorschlaege: MetaVorschlag[]
|
||||||
|
schliessen: () => void
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(startText)
|
||||||
|
useEffect(() => {
|
||||||
|
metadatenSuchen(id, startText)
|
||||||
|
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<DialogRahmen schliessen={schliessen} breite="max-w-lg">
|
||||||
|
<h3 className="mb-3 font-display text-lg font-bold text-schnee">Was ist das wirklich?</h3>
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') metadatenSuchen(id, text)
|
||||||
|
}}
|
||||||
|
placeholder="Titel suchen …"
|
||||||
|
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => metadatenSuchen(id, text)}
|
||||||
|
className="rounded-lg border border-gold/50 bg-gold/10 px-4 py-2 text-xs font-semibold text-gold-hell hover:bg-gold/20"
|
||||||
|
>
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{vorschlaege.length === 0 ? (
|
||||||
|
<p className="font-mono text-xs text-nebel">Keine Treffer — anderen Suchtext probieren.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{vorschlaege.map((v) => (
|
||||||
|
<button
|
||||||
|
key={`${v.typ}-${v.tmdbId}`}
|
||||||
|
onClick={() => {
|
||||||
|
zuordnungSetzen(id, v)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl border border-tinte/10 bg-karte/60 p-2 text-left hover:border-gold/50 hover:bg-karte"
|
||||||
|
>
|
||||||
|
{v.posterPfad.length > 0 ? (
|
||||||
|
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-16 w-11 rounded-md object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="flex h-16 w-11 items-center justify-center rounded-md bg-samt">
|
||||||
|
<IconDisc groesse={20} farbe="oklch(0.55 0.015 80)" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-schnee">
|
||||||
|
{v.titel}
|
||||||
|
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] tracking-wider text-nebel">{v.typ === 'tv' ? 'SERIE' : 'FILM'}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 text-right">
|
||||||
|
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:bg-tinte/5">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogRahmen>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
|
||||||
|
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
|
||||||
|
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb
|
||||||
|
// beginnt er erst hier und nicht schon beim Einlegen.
|
||||||
|
// 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau.
|
||||||
|
// 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1
|
||||||
|
// zählt auf Disc 2 weiter).
|
||||||
|
// 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen
|
||||||
|
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
||||||
|
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
||||||
|
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
||||||
|
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
|
||||||
|
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
|
||||||
|
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
|
||||||
|
// einzelnen Spuren meldet.
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
||||||
|
import { platzUrteil } from '../../gemeinsam/platz'
|
||||||
|
import type {
|
||||||
|
DiscInfoStand,
|
||||||
|
DiscTitelVorschlag,
|
||||||
|
GeraeteInfo,
|
||||||
|
MetaVorschlag,
|
||||||
|
SpeicherStand,
|
||||||
|
SprachEintrag,
|
||||||
|
SpurAngabe,
|
||||||
|
TitelRolle,
|
||||||
|
TitelStand,
|
||||||
|
} from '../../gemeinsam/nachrichten'
|
||||||
|
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
|
||||||
|
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
||||||
|
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
|
||||||
|
|
||||||
|
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
||||||
|
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
||||||
|
function SprachChips({
|
||||||
|
name,
|
||||||
|
vorhanden,
|
||||||
|
gewaehlt,
|
||||||
|
aufWechsel,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
vorhanden: readonly SprachEintrag[]
|
||||||
|
gewaehlt: readonly string[]
|
||||||
|
aufWechsel: (lang: string) => void
|
||||||
|
}) {
|
||||||
|
if (vorhanden.length === 0) return null
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-1">
|
||||||
|
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||||||
|
{vorhanden.map((s) => {
|
||||||
|
const an = gewaehlt.includes(s.lang)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.lang}
|
||||||
|
type="button"
|
||||||
|
onClick={() => aufWechsel(s.lang)}
|
||||||
|
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
|
||||||
|
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst'
|
||||||
|
}`}
|
||||||
|
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
|
||||||
|
>
|
||||||
|
{an ? '✓ ' : ''}
|
||||||
|
{s.lang}
|
||||||
|
{s.spuren > 1 ? `×${s.spuren}` : ''}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
|
||||||
|
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
|
||||||
|
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
|
||||||
|
function SpurZeilen({
|
||||||
|
name,
|
||||||
|
art,
|
||||||
|
vorhanden,
|
||||||
|
gewaehlt,
|
||||||
|
aufWechsel,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
art: 'audio' | 'untertitel'
|
||||||
|
vorhanden: readonly SpurAngabe[]
|
||||||
|
gewaehlt: readonly number[]
|
||||||
|
aufWechsel: (nr: number) => void
|
||||||
|
}) {
|
||||||
|
const zeilen = vorhanden.filter((s) => angeboten(s, art))
|
||||||
|
if (zeilen.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const an = gewaehlt.includes(s.nr)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={s.nr}
|
||||||
|
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
|
||||||
|
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||||||
|
{spurLabel(s)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
|
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
|
||||||
|
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
|
||||||
|
* der Wunschsprache — sonst nichts. */
|
||||||
|
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
|
||||||
|
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
|
||||||
|
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
|
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
||||||
|
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
||||||
|
if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return ''
|
||||||
|
return untertitel.includes(wunsch) ? wunsch : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitelWahlDialog({
|
||||||
|
id,
|
||||||
|
typ,
|
||||||
|
stand,
|
||||||
|
info,
|
||||||
|
einstellungen,
|
||||||
|
regel,
|
||||||
|
speicher,
|
||||||
|
vorschlaege,
|
||||||
|
schliessen,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
typ: GeraeteInfo['typ']
|
||||||
|
stand: TitelStand | undefined
|
||||||
|
info: DiscInfoStand | undefined
|
||||||
|
einstellungen: Record<string, string>
|
||||||
|
/** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem
|
||||||
|
* Roh-Material passiert. */
|
||||||
|
regel: string
|
||||||
|
speicher: SpeicherStand | null
|
||||||
|
/** Treffer der Metadaten-Suche (für „anderer Film …"). */
|
||||||
|
vorschlaege: MetaVorschlag[]
|
||||||
|
schliessen: () => void
|
||||||
|
}) {
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
||||||
|
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
|
||||||
|
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
|
||||||
|
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
|
||||||
|
// Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt
|
||||||
|
// (Einstellung, soweit der Titel sie hat — sonst alles, was er hat).
|
||||||
|
const [sprachen, setSprachen] = useState<Record<number, { audio: string[]; untertitel: string[] }>>({})
|
||||||
|
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
||||||
|
// sonst die Sprache. Immer optional, nie eingebrannt.
|
||||||
|
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
||||||
|
// 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
|
||||||
|
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
|
||||||
|
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
|
||||||
|
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
|
||||||
|
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
|
||||||
|
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
|
||||||
|
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
||||||
|
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
||||||
|
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
||||||
|
// Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der
|
||||||
|
// Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser
|
||||||
|
// Serie) oder vom Nutzer („auf Disc 2 sind 3–4").
|
||||||
|
const merker = info?.serienMerker ?? null
|
||||||
|
const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1)
|
||||||
|
const [staffel, setStaffel] = useState<string>(
|
||||||
|
info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '',
|
||||||
|
)
|
||||||
|
const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '')
|
||||||
|
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||||
|
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
|
||||||
|
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
|
||||||
|
const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } =>
|
||||||
|
sprachen[t.nr] ?? {
|
||||||
|
audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''),
|
||||||
|
untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''),
|
||||||
|
}
|
||||||
|
const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => {
|
||||||
|
const jetzt = sprachenVon(t)
|
||||||
|
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
||||||
|
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
|
}
|
||||||
|
const wunschListe = (schluessel: string): string[] =>
|
||||||
|
(einstellungen[schluessel] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
|
||||||
|
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
|
||||||
|
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
|
||||||
|
spuren[t.nr] ?? {
|
||||||
|
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
|
||||||
|
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
|
||||||
|
}
|
||||||
|
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
|
||||||
|
const jetzt = spurenVon(t)
|
||||||
|
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
|
||||||
|
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
|
}
|
||||||
|
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
|
||||||
|
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
|
||||||
|
const nrn = spurenVon(t)
|
||||||
|
return {
|
||||||
|
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
|
||||||
|
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const umschalten = (nr: number): void => {
|
||||||
|
const neu = new Set(aktiv)
|
||||||
|
if (neu.has(nr)) neu.delete(nr)
|
||||||
|
else neu.add(nr)
|
||||||
|
setGewaehlt(neu)
|
||||||
|
}
|
||||||
|
const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? ''
|
||||||
|
const alle = stand?.titel ?? []
|
||||||
|
const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr))
|
||||||
|
const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0)
|
||||||
|
const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge')
|
||||||
|
const eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined)
|
||||||
|
const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length
|
||||||
|
const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length
|
||||||
|
// Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau.
|
||||||
|
const andereFilme = new Map<number, { film: MetaVorschlag; titel: number }>()
|
||||||
|
for (const t of gewaehlteTitel) {
|
||||||
|
const f = filme[t.nr]
|
||||||
|
if (f === undefined) continue
|
||||||
|
const e = andereFilme.get(f.tmdbId)
|
||||||
|
if (e !== undefined) e.titel += 1
|
||||||
|
else andereFilme.set(f.tmdbId, { film: f, titel: 1 })
|
||||||
|
}
|
||||||
|
const zahlOderNull = (text: string, mindestens: number): number | null => {
|
||||||
|
const n = Number(text.trim())
|
||||||
|
return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null
|
||||||
|
}
|
||||||
|
const staffelZahl = zahlOderNull(staffel, 0)
|
||||||
|
const folgeZahl = zahlOderNull(ersteFolge, 1)
|
||||||
|
const ablage = einstellungen['ablage'] ?? 'Videos\\Rippy'
|
||||||
|
const vorschau = zielVorschau({
|
||||||
|
ablage,
|
||||||
|
alsSerie,
|
||||||
|
titel: info?.titel ?? '',
|
||||||
|
jahr: info?.jahr ?? null,
|
||||||
|
staffel: staffelZahl,
|
||||||
|
ersteFolge: folgeZahl,
|
||||||
|
hauptTitel,
|
||||||
|
extras,
|
||||||
|
vorgangsName: `${typ}-<Zeitstempel>`,
|
||||||
|
})
|
||||||
|
// Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode.
|
||||||
|
const komprimiert = einstellungen['transcodeEnabled'] !== 'false'
|
||||||
|
const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false }
|
||||||
|
const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
|
||||||
|
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||||
|
|
||||||
|
{stand === undefined || stand.laeuft ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc … meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
|
||||||
|
{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange
|
||||||
|
es schon läuft und wo die Grenze liegt. */}
|
||||||
|
{stand !== undefined && stand.einordnung.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
|
||||||
|
</div>
|
||||||
|
) : stand.fehler.length > 0 ? (
|
||||||
|
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
|
||||||
|
{stand.unsicher ? '⚠ ' : ''}
|
||||||
|
{stand.einordnung}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
|
||||||
|
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
|
||||||
|
>
|
||||||
|
Rippys Vorschlag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setGewaehlt(
|
||||||
|
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
|
||||||
|
// Sammeltitel noch Logos/Warnungen.
|
||||||
|
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Hauptinhalt + Extras
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||||
|
Alles
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||||
|
Nichts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{alle.map((t) => {
|
||||||
|
const an = aktiv.has(t.nr)
|
||||||
|
const wahl = sprachenVon(t)
|
||||||
|
const rolle = rolleVon(t)
|
||||||
|
const film = filme[t.nr]
|
||||||
|
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
||||||
|
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
||||||
|
const spurNrn = spurenVon(t)
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
|
||||||
|
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
|
||||||
|
return (
|
||||||
|
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 p-2">
|
||||||
|
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||||||
|
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
|
||||||
|
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
|
||||||
|
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
|
||||||
|
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
|
||||||
|
{an ? (
|
||||||
|
<select
|
||||||
|
value={rolle}
|
||||||
|
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
|
||||||
|
title="Bestimmt, WOHIN der Titel abgelegt wird"
|
||||||
|
>
|
||||||
|
<option value="hauptfilm">Hauptfilm</option>
|
||||||
|
<option value="extra">Extra</option>
|
||||||
|
<option value="folge">Folge</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
|
||||||
|
der Info-Lauf an DIESEM Titel gemessen hat — einzeln
|
||||||
|
anhaken, was in die Datei soll. */}
|
||||||
|
{an && spurenDa(t) && (
|
||||||
|
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
|
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
|
||||||
|
<SpurZeilen
|
||||||
|
name="UNTERTITEL"
|
||||||
|
art="untertitel"
|
||||||
|
vorhanden={t.spuren.untertitel}
|
||||||
|
gewaehlt={spurNrn.untertitel}
|
||||||
|
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
|
||||||
|
/>
|
||||||
|
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
|
||||||
|
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt — HandBrake nimmt die erste Spur</span>
|
||||||
|
)}
|
||||||
|
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
|
||||||
|
weiche Spur, die der Spieler von selbst einblendet;
|
||||||
|
eingebrannt wird nie. Hier als SPUR. */}
|
||||||
|
{rolle === 'extra' && spurWahl.untertitel.length > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||||||
|
<select
|
||||||
|
value={vorwahlSpurWert}
|
||||||
|
onChange={(e) => {
|
||||||
|
const w = e.target.value
|
||||||
|
const neu = { ...vorwahlSpur }
|
||||||
|
if (w === 'regel') delete neu[t.nr]
|
||||||
|
else neu[t.nr] = w === 'keine' ? null : Number(w)
|
||||||
|
setVorwahlSpur(neu)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||||||
|
>
|
||||||
|
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
|
||||||
|
<option value="keine">keine</option>
|
||||||
|
{spurWahl.untertitel.map((u) => (
|
||||||
|
<option key={u.nr} value={String(u.nr)}>
|
||||||
|
{spurLabel(u)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
||||||
|
Titel gemessen hat — anhaken, was in die Datei soll.
|
||||||
|
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
|
||||||
|
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
|
||||||
|
{an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
|
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
||||||
|
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
||||||
|
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
|
||||||
|
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt — HandBrake nimmt die erste Spur</span>
|
||||||
|
)}
|
||||||
|
{/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional — eine
|
||||||
|
weiche Spur, die der Spieler von selbst einblendet;
|
||||||
|
eingebrannt wird nie. */}
|
||||||
|
{rolle === 'extra' && wahl.untertitel.length > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||||||
|
<select
|
||||||
|
value={vorwahlWert}
|
||||||
|
onChange={(e) => {
|
||||||
|
const w = e.target.value
|
||||||
|
const neu = { ...vorwahl }
|
||||||
|
if (w === 'regel') delete neu[t.nr]
|
||||||
|
else neu[t.nr] = w === 'keine' ? null : w
|
||||||
|
setVorwahl(neu)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||||||
|
>
|
||||||
|
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
|
||||||
|
<option value="keine">keine</option>
|
||||||
|
{wahl.untertitel.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc,
|
||||||
|
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
|
||||||
|
{an && rolle !== 'folge' && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
|
{film !== undefined ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||||||
|
→ eigener Film: {film.titel}
|
||||||
|
{film.jahr !== null ? ` (${film.jahr})` : ''}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const neu = { ...filme }
|
||||||
|
delete neu[t.nr]
|
||||||
|
setFilme(neu)
|
||||||
|
}}
|
||||||
|
className="ml-1 text-nebel hover:text-rot"
|
||||||
|
title="wieder zur Disc-Zuordnung"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
|
||||||
|
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
|
||||||
|
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
|
||||||
|
>
|
||||||
|
anderer Film …
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filmSuche?.nr === t.nr && (
|
||||||
|
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={filmSuche.text}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setFilmSuche({ nr: t.nr, text: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text)
|
||||||
|
}}
|
||||||
|
placeholder="Filmtitel suchen …"
|
||||||
|
className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => metadatenSuchen(id, filmSuche.text)}
|
||||||
|
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
|
||||||
|
>
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{filmTreffer.length === 0 ? (
|
||||||
|
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer — Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{filmTreffer.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.tmdbId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilme({ ...filme, [t.nr]: v })
|
||||||
|
setFilmSuche(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
|
||||||
|
>
|
||||||
|
{v.posterPfad.length > 0 ? (
|
||||||
|
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
|
||||||
|
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-schnee">
|
||||||
|
{v.titel}
|
||||||
|
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alsSerie && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={staffel}
|
||||||
|
onChange={(e) => setStaffel(e.target.value)}
|
||||||
|
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
|
||||||
|
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={ersteFolge}
|
||||||
|
onChange={(e) => setErsteFolge(e.target.value)}
|
||||||
|
placeholder="automatisch"
|
||||||
|
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
||||||
|
{merkerGilt && merker !== null
|
||||||
|
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
|
||||||
|
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
|
||||||
|
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden
|
||||||
|
einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0:
|
||||||
|
jeder weitere Film bekommt seine Zeile. */}
|
||||||
|
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
|
||||||
|
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
|
||||||
|
{eigeneTitel.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="break-all text-gold-hell">{vorschau[0]}</p>
|
||||||
|
<p className="text-dunst">{vorschau[1]}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{[...andereFilme.values()].map(({ film, titel }) => (
|
||||||
|
<p key={film.tmdbId} className="break-all text-gold-hell">
|
||||||
|
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
|
||||||
|
<span className="text-dunst">
|
||||||
|
{' '}
|
||||||
|
— {titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
|
||||||
|
vollen Laufwerk scheitern. */}
|
||||||
|
{speicher !== null && speicher.arbeit.ok && (
|
||||||
|
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 0 ? 'text-warn' : 'text-nebel'}`}>
|
||||||
|
{platz.text.length > 0
|
||||||
|
? `${platz.hart ? '✗' : '⚠'} ${platz.text}`
|
||||||
|
: `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||||
|
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
|
||||||
|
{regel.length > 0 ? ` · ${regel}` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-nebel">
|
||||||
|
{alsSerie
|
||||||
|
? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
|
||||||
|
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '}
|
||||||
|
Das Laufwerk ist frei, sobald die Roh-Dateien liegen — die Kompression läuft danach im Hintergrund.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
|
||||||
|
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
|
||||||
|
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
ripStarten(
|
||||||
|
id,
|
||||||
|
gewaehlteTitel.map((t) => {
|
||||||
|
const wahl = sprachenVon(t)
|
||||||
|
const rolle = rolleVon(t)
|
||||||
|
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
|
||||||
|
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
|
||||||
|
// scheitert; und die alten Hinweise bleiben stimmig).
|
||||||
|
if (spurenDa(t)) {
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
return {
|
||||||
|
nr: t.nr,
|
||||||
|
rolle,
|
||||||
|
dauerS: t.dauerS,
|
||||||
|
groesseBytes: t.groesseBytes,
|
||||||
|
audioSprachen: sprachenDerSpuren(spurWahl.audio),
|
||||||
|
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
|
||||||
|
audioSpuren: spurWahl.audio,
|
||||||
|
untertitelSpuren: spurWahl.untertitel,
|
||||||
|
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
|
||||||
|
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nr: t.nr,
|
||||||
|
rolle,
|
||||||
|
dauerS: t.dauerS,
|
||||||
|
groesseBytes: t.groesseBytes,
|
||||||
|
audioSprachen: wahl.audio,
|
||||||
|
untertitelSprachen: wahl.untertitel,
|
||||||
|
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
|
||||||
|
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
|
||||||
|
)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
|
||||||
|
className={KNOPF_GOLD}
|
||||||
|
title={platz.hart ? platz.text : ''}
|
||||||
|
>
|
||||||
|
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogRahmen>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ────────
|
||||||
|
// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein
|
||||||
|
// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein
|
||||||
|
// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen.
|
||||||
|
// 5.5.0: dieselben Bausteine wie die Einstellungen (Commander: „All diese
|
||||||
|
// Dinge müssen auch in den FirstRun mit rein") — Ablage und Arbeitsordner,
|
||||||
|
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
||||||
|
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { DEFAULT_HB_PRESET, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
|
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
||||||
|
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
||||||
|
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
||||||
|
import { PosterHintergrund } from './PosterHintergrund'
|
||||||
|
|
||||||
|
const SCHRITTE = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Meldungen', 'Automatik', 'Fertig']
|
||||||
|
|
||||||
|
export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const [schritt, setSchritt] = useState(0)
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const werkzeuge = verbindung.werkzeuge
|
||||||
|
const speicher = verbindung.speicher
|
||||||
|
const setzen = einstellungSetzen
|
||||||
|
const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0
|
||||||
|
const tmdbDa = (werte['tmdbApiKey'] ?? '').length > 0
|
||||||
|
|
||||||
|
// Poster, sobald ein TMDb-Schlüssel da ist — auch, wenn er gerade erst
|
||||||
|
// eingetragen wurde.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tmdbDa) posterLaden()
|
||||||
|
}, [tmdbDa])
|
||||||
|
useEffect(() => {
|
||||||
|
speicherLaden()
|
||||||
|
}, [schritt])
|
||||||
|
|
||||||
|
const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [
|
||||||
|
{
|
||||||
|
ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0,
|
||||||
|
pflicht: true,
|
||||||
|
text:
|
||||||
|
werkzeuge === null
|
||||||
|
? 'MakeMKV wird gesucht …'
|
||||||
|
: werkzeuge.makemkvcon.length > 0
|
||||||
|
? `MakeMKV gefunden: ${werkzeuge.makemkvcon}`
|
||||||
|
: 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst — oder unten den Pfad wählen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0,
|
||||||
|
pflicht: false,
|
||||||
|
text: werkzeuge === null ? 'HandBrakeCLI wird gesucht …' : werkzeuge.handbrake.length > 0 ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: werkzeuge === null ? null : werkzeuge.flac.length > 0,
|
||||||
|
pflicht: false,
|
||||||
|
text: werkzeuge === null ? 'FLAC wird gesucht …' : werkzeuge.flac.length > 0 ? `FLAC dabei: ${werkzeuge.flac}` : 'FLAC fehlt — nur für Audio-CDs nötig.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: verbindung.laufwerke.length > 0 ? true : null,
|
||||||
|
pflicht: false,
|
||||||
|
text:
|
||||||
|
verbindung.laufwerke.length > 0
|
||||||
|
? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})`
|
||||||
|
: 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
|
||||||
|
// Preset-Empfehlung (Commander-Fund 01.09.2026: sie war unsichtbar).
|
||||||
|
const backends = werkzeuge?.backends ?? []
|
||||||
|
const presets = werkzeuge?.presets ?? []
|
||||||
|
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
|
||||||
|
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['DVD', 'dvd'],
|
||||||
|
['Blu-ray', 'bluray'],
|
||||||
|
['4K-UHD', 'uhd'],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PosterHintergrund pfade={verbindung.poster} />
|
||||||
|
<div className="relative mx-auto max-w-2xl space-y-7 rounded-2xl border border-tinte/10 bg-buehne/80 p-6 pt-5 shadow-2xl shadow-black/60 backdrop-blur">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SCHRITTE.map((name, i) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs ${i === schritt ? 'border border-gold/40 bg-gold/15 text-gold-hell' : i < schritt ? 'text-gruen' : 'text-nebel'}`}
|
||||||
|
>
|
||||||
|
{i + 1} {name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schritt === 0 && (
|
||||||
|
<div className="space-y-3 text-sm text-dunst">
|
||||||
|
<h2 className="font-display text-3xl font-bold text-schnee">Willkommen bei Rippy</h2>
|
||||||
|
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.</p>
|
||||||
|
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und startet bei sicherer Erkennung von selbst.</p>
|
||||||
|
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
|
||||||
|
<p>Rippy prüft gleich seine Werkzeuge und fragt dich danach ein paar Dinge: wohin, wie schnell, woher die Titel kommen, wie es sich meldet. Dauert zwei Minuten — ändern kannst du alles später.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 1 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pruefzeilen.map((zeile, i) => (
|
||||||
|
<p key={i} className={`font-mono text-xs ${zeile.ok === true ? 'text-gruen' : zeile.ok === null ? 'text-nebel' : zeile.pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||||
|
{zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{makemkvFehlt && (
|
||||||
|
<PfadFeld
|
||||||
|
name="makemkvcon.exe von Hand wählen"
|
||||||
|
hinweis="falls MakeMKV an einem ungewöhnlichen Ort liegt"
|
||||||
|
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||||
|
art="programm"
|
||||||
|
dialogTitel="makemkvcon.exe wählen"
|
||||||
|
aufAendern={(w) => setzen('werkzeug.makemkv', w)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder? Ein Netzlaufwerk geht — Rippy baut darin selbst die Struktur.</p>
|
||||||
|
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
|
||||||
|
{speicher !== null && speicher.ablage.ok && <p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}</p>}
|
||||||
|
<p className="text-sm text-dunst">
|
||||||
|
Liegt die Ablage auf einem Server, wähle einen <span className="text-schnee">Arbeitsordner auf einer lokalen Platte</span>: Dort liegen die Roh-Dateien
|
||||||
|
(30 GB und mehr je Blu-ray) und dort rechnet HandBrake. Nur das fertige Ergebnis wandert übers Netz.
|
||||||
|
</p>
|
||||||
|
<PfadFeld
|
||||||
|
name="Arbeitsordner (lokal, optional)"
|
||||||
|
hinweis="leer = unter der Ablage"
|
||||||
|
wert={werte['arbeitsordner'] ?? ''}
|
||||||
|
art="ordner"
|
||||||
|
dialogTitel="Arbeitsordner wählen (lokale Platte)"
|
||||||
|
aufAendern={(w) => setzen('arbeitsordner', w)}
|
||||||
|
/>
|
||||||
|
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
|
||||||
|
<p className="font-mono text-[10.5px] text-nebel">{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-dunst">Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung berechnen — die kostet Zeit, spart aber viel Platz.</p>
|
||||||
|
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
|
||||||
|
{werkzeuge === null ? (
|
||||||
|
<p className="font-mono text-xs text-nebel">Encoder werden gemessen …</p>
|
||||||
|
) : backends.length === 0 ? (
|
||||||
|
<p className="font-mono text-xs text-warn">Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">AUF DIESER MASCHINE GEMESSEN:</span>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-dunst">Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:</p>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||||
|
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
||||||
|
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
||||||
|
<p className="mt-0.5 font-mono text-xs text-gold-hell">{empfehlungText(typ, presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{nurCpu && <p className="mt-2 text-xs text-warn">Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der verlustfreie Rip ist davon nicht betroffen.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<SprachAuswahl name="Tonspuren" hinweis="die erste ist deine Sprache" wert={werte['audioSprachen'] ?? ''} aufAendern={(w) => setzen('audioSprachen', w)} />
|
||||||
|
<SprachAuswahl name="Untertitel" hinweis="vorgewählt bei fremdem Ton" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-nebel">Je Disc-Typ ein eigenes Preset geht später unter „Einstellungen → Kompression" — deine Wahl schlägt die Empfehlung immer.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-dunst">Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen kostenlosen TMDb-Schlüssel.</p>
|
||||||
|
<Feld name="TMDb-API-Key" hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt" wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||||
|
{tmdbDa && verbindung.poster.length > 0 && <p className="font-mono text-xs text-gruen">✓ Der Schlüssel funktioniert — die Poster im Hintergrund kommen von TMDb.</p>}
|
||||||
|
<p className="text-xs text-nebel">Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen → Metadaten".</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 5 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-dunst">Soll Rippy sich aufs Handy melden, wenn ein Rip fertig ist oder etwas schiefgeht? Windows-Meldungen kommen immer — das hier ist freiwillig.</p>
|
||||||
|
<Feld name="Discord-Webhook" hinweis="Servereinstellungen → Integrationen → Webhooks" geheim wert={werte['discordWebhook'] ?? ''} aufAendern={(w) => setzen('discordWebhook', w)} />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
|
||||||
|
<Feld name="Telegram-Chat-ID" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
||||||
|
Testnachricht schicken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 6 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-dunst">Wie selbstständig soll Rippy sein?</p>
|
||||||
|
<Schalter an={werte['automatik'] !== 'aus'} aufWechsel={(an) => setzen('automatik', an ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) nach einem Countdown von selbst rippen — mit Stopp-Knopf" />
|
||||||
|
<label className="block max-w-xs">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Countdown</span>
|
||||||
|
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="10">10 Sekunden</option>
|
||||||
|
<option value="30">30 Sekunden (Vorgabe)</option>
|
||||||
|
<option value="60">1 Minute</option>
|
||||||
|
<option value="120">2 Minuten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Schalter an={werte['autoAuswurf'] !== 'false'} aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')} label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft" />
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Roh-Dateien nach gelungener Kompression</span>
|
||||||
|
<select value={werte['rohAufbewahrung'] ?? '7'} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="sofort">sofort löschen</option>
|
||||||
|
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
|
||||||
|
<option value="14">nach 14 Tagen löschen</option>
|
||||||
|
<option value="30">nach 30 Tagen löschen</option>
|
||||||
|
<option value="behalten">behalten — nur von Hand löschen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-nebel">Die Roh-Dateien sind der Grund, warum ein Preset-Wechsel kein Neulesen kostet — und gelöscht wird nur, wenn das fertige Ergebnis nachweislich da ist.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 7 && (
|
||||||
|
<div className="space-y-3 text-sm text-dunst">
|
||||||
|
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
|
||||||
|
<p className="text-xs text-nebel">Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.</p>
|
||||||
|
{makemkvFehlt && <p className="text-xs text-rot">Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt den Stand, sobald es installiert ist).</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button onClick={() => setSchritt((s) => Math.max(0, s - 1))} disabled={schritt === 0} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee disabled:opacity-35">
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
{schritt < SCHRITTE.length - 1 ? (
|
||||||
|
<button onClick={() => setSchritt((s) => s + 1)} className={KNOPF_GOLD}>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setzen('setup.done', 'true')} className={KNOPF_GOLD}>
|
||||||
|
Los geht's
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Der Poster-Hintergrund der Erst-Einrichtung (5.5.0, Commander: „diverse
|
||||||
|
// Movie Cover die … im Hintergrund in nem Grid langsam von oben nach unten
|
||||||
|
// laufen"). Vor dem TMDb-Schlüssel ein ruhiges Raster leerer Film-Karten,
|
||||||
|
// danach echte Poster beliebter Filme (kern: erkennung.posterLaden). Die
|
||||||
|
// Spalten laufen langsam, jede in eigenem Tempo, die Liste ist verdoppelt,
|
||||||
|
// damit der Lauf nahtlos ist (Keyframes in stil.css: poster-lauf).
|
||||||
|
import { IconDisc, posterUrlGross } from '../bausteine'
|
||||||
|
|
||||||
|
const SPALTEN = 7
|
||||||
|
const JE_SPALTE = 6
|
||||||
|
|
||||||
|
export function PosterHintergrund({ pfade }: { pfade: string[] }) {
|
||||||
|
const spalten: string[][] = Array.from({ length: SPALTEN }, () => [])
|
||||||
|
pfade.forEach((p, i) => spalten[i % SPALTEN].push(p))
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<div className="absolute inset-0 flex gap-4 px-4 opacity-[0.5]">
|
||||||
|
{spalten.map((spalte, i) => {
|
||||||
|
// Auffüllen mit leeren Karten, damit jede Spalte voll ist.
|
||||||
|
const karten: Array<string | null> = [...spalte]
|
||||||
|
while (karten.length < JE_SPALTE) karten.push(null)
|
||||||
|
const doppelt = [...karten, ...karten]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="poster-spalte flex flex-1 flex-col gap-4"
|
||||||
|
style={{
|
||||||
|
animationDuration: `${70 + (i % 3) * 25}s`,
|
||||||
|
animationDelay: `${-i * 9}s`,
|
||||||
|
marginTop: `${(i % 2) * -120}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doppelt.map((p, j) =>
|
||||||
|
p !== null ? (
|
||||||
|
<img key={j} src={posterUrlGross(p)} alt="" className="aspect-[2/3] w-full rounded-xl object-cover ring-1 ring-gold/25" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div key={j} className="flex aspect-[2/3] w-full items-center justify-center rounded-xl bg-gradient-to-br from-[oklch(0.27_0.03_75)] to-[oklch(0.19_0.02_75)] ring-1 ring-gold/30">
|
||||||
|
<IconDisc groesse={28} farbe="oklch(0.6 0.08 75)" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Der Verlauf ins Bühnen-Schwarz — die Poster bleiben Kulisse, der
|
||||||
|
Assistent bleibt lesbar. */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-buehne/35 via-buehne/55 to-buehne/90" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Die Einstellungen — Sektionswahl links (oder oben im schmalen Fenster),
|
||||||
|
// die Karten rechts. Jede Karte ist eine Datei in Karten.tsx (5.5.0).
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { HauptStatus } from '../../gemeinsam/nachrichten'
|
||||||
|
import type { Verbindung } from '../kernverbindung'
|
||||||
|
import {
|
||||||
|
AblageKarte,
|
||||||
|
AllgemeinKarte,
|
||||||
|
AutomatikKarte,
|
||||||
|
BenachrichtigungKarte,
|
||||||
|
KompressionKarte,
|
||||||
|
MakemkvKarte,
|
||||||
|
MedienserverKarte,
|
||||||
|
MetadatenKarte,
|
||||||
|
RohDateienKarte,
|
||||||
|
UpdateKarte,
|
||||||
|
WerkzeugeKarte,
|
||||||
|
} from './Karten'
|
||||||
|
|
||||||
|
const SEKTIONEN: Array<[string, string]> = [
|
||||||
|
['ablage', 'Ablage'],
|
||||||
|
['kompression', 'Kompression'],
|
||||||
|
['roh', 'Roh-Dateien'],
|
||||||
|
['automatik', 'Automatik'],
|
||||||
|
['metadaten', 'Metadaten'],
|
||||||
|
['makemkv', 'MakeMKV'],
|
||||||
|
['medienserver', 'Medienserver'],
|
||||||
|
['benachrichtigung', 'Benachrichtigung'],
|
||||||
|
['allgemein', 'Allgemein'],
|
||||||
|
['update', 'Update'],
|
||||||
|
['werkzeuge', 'Werkzeuge'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbindung; haupt: HauptStatus | null; start?: string }) {
|
||||||
|
const [aktiv, setAktiv] = useState(start ?? 'ablage')
|
||||||
|
|
||||||
|
const springe = (id: string): void => {
|
||||||
|
setAktiv(id)
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
// „alle unter Roh-Dateien →" aus der Übersicht springt direkt zur Karte.
|
||||||
|
useEffect(() => {
|
||||||
|
if (start !== undefined) setTimeout(() => springe(start), 50)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [start])
|
||||||
|
|
||||||
|
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
|
||||||
|
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
|
||||||
|
// 01.09.2026: „alles gequetscht").
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
|
||||||
|
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-[76px] lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
|
||||||
|
{SEKTIONEN.map(([id, name]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => springe(id)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
|
||||||
|
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-tinte/5 hover:text-schnee'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
|
||||||
|
breiten Fenster alles links klebte und rechts nichts stand. */}
|
||||||
|
<div className="min-w-0 flex-1 space-y-5">
|
||||||
|
<AblageKarte verbindung={verbindung} />
|
||||||
|
<KompressionKarte verbindung={verbindung} />
|
||||||
|
<RohDateienKarte verbindung={verbindung} aktivFuer={start === 'roh'} />
|
||||||
|
<AutomatikKarte verbindung={verbindung} />
|
||||||
|
<MetadatenKarte verbindung={verbindung} />
|
||||||
|
<MakemkvKarte verbindung={verbindung} />
|
||||||
|
<MedienserverKarte verbindung={verbindung} />
|
||||||
|
<BenachrichtigungKarte verbindung={verbindung} />
|
||||||
|
<AllgemeinKarte verbindung={verbindung} haupt={haupt} />
|
||||||
|
<UpdateKarte verbindung={verbindung} haupt={haupt} />
|
||||||
|
<WerkzeugeKarte verbindung={verbindung} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
|
||||||
|
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
|
||||||
|
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
|
||||||
|
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||||
|
import { empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
FELD_KLASSE,
|
||||||
|
Feld,
|
||||||
|
HinterlegtPunkt,
|
||||||
|
KNOPF_LEISE,
|
||||||
|
LAUFENDE_PHASEN,
|
||||||
|
PfadFeld,
|
||||||
|
Schalter,
|
||||||
|
SektionsKarte,
|
||||||
|
TYP_NAMEN,
|
||||||
|
datumKurz,
|
||||||
|
gb,
|
||||||
|
gbKurz,
|
||||||
|
} from '../bausteine'
|
||||||
|
import {
|
||||||
|
benachrichtigungTesten,
|
||||||
|
beschaffungAntworten,
|
||||||
|
einstellungSetzen,
|
||||||
|
fremdenOrdnerLoeschen,
|
||||||
|
kompressionAbbrechen,
|
||||||
|
makemkvUpdate,
|
||||||
|
rohAufraeumen,
|
||||||
|
schluesselPruefen,
|
||||||
|
vorgaengeLaden,
|
||||||
|
vorgangNeuKomprimieren,
|
||||||
|
vorgangRohLoeschen,
|
||||||
|
type Verbindung,
|
||||||
|
} from '../kernverbindung'
|
||||||
|
import { phasenTon } from '../uebersicht/VerlaufKarte'
|
||||||
|
import { SprachAuswahl } from './SprachAuswahl'
|
||||||
|
|
||||||
|
const setzen = einstellungSetzen
|
||||||
|
|
||||||
|
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const speicher = verbindung.speicher
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="ablage"
|
||||||
|
titel="ABLAGE"
|
||||||
|
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
|
||||||
|
>
|
||||||
|
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
|
||||||
|
{speicher !== null && speicher.ablage.ok && (
|
||||||
|
<p className="font-mono text-[10.5px] text-nebel">
|
||||||
|
{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<PfadFeld
|
||||||
|
name="Arbeitsordner (lokal, optional)"
|
||||||
|
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
|
||||||
|
wert={werte['arbeitsordner'] ?? ''}
|
||||||
|
art="ordner"
|
||||||
|
dialogTitel="Arbeitsordner wählen (lokale Platte)"
|
||||||
|
aufAendern={(w) => setzen('arbeitsordner', w)}
|
||||||
|
/>
|
||||||
|
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
|
||||||
|
<p className="font-mono text-[10.5px] text-nebel">
|
||||||
|
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
<p className="text-dunst">So legt Rippy ab:</p>
|
||||||
|
<p>
|
||||||
|
├─ Filme\Akira (1988)\ <span className="text-nebel/70">— Film, Poster und Infodatei beieinander</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
├─ Serien\Spartacus\Season 01\ <span className="text-nebel/70">— Folgen, wie Jellyfin sie erwartet</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
├─ Musik\ <span className="text-nebel/70">— Audio-CDs als FLAC, benannt nach MusicBrainz</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
├─ ISO\ <span className="text-nebel/70">— unerkannte Discs als 1:1-Abbild</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
└─ roh\ <span className="text-nebel/70">— im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Schalter
|
||||||
|
an={werte['autoAuswurf'] !== 'false'}
|
||||||
|
aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')}
|
||||||
|
label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft"
|
||||||
|
/>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetWahl({
|
||||||
|
name,
|
||||||
|
schluessel,
|
||||||
|
wert,
|
||||||
|
presets,
|
||||||
|
empfehlung,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
schluessel: string
|
||||||
|
wert: string
|
||||||
|
presets: string[]
|
||||||
|
empfehlung: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||||
|
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
|
||||||
|
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<option key={preset} value={preset}>
|
||||||
|
{preset}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
|
const backends = verbindung.werkzeuge?.backends ?? []
|
||||||
|
const empfehlungFuer = (typ: string): string => empfehlungText(typ, presetEmpfehlung(typ, backends, presets))
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="kompression"
|
||||||
|
titel="KOMPRESSION"
|
||||||
|
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
|
||||||
|
>
|
||||||
|
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
||||||
|
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
|
||||||
|
)}
|
||||||
|
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
|
||||||
|
{backends.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} />
|
||||||
|
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
|
||||||
|
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
||||||
|
schlägt die Empfehlung immer. 4K-UHD wird nur mit Grafikkarten-Encoder komprimiert (8 Bit); ohne bleibt es verlustfrei. Wer HDR in 10 Bit will, wählt
|
||||||
|
„AV1 VCN 2160p 4K" — die Abspielgeräte müssen dann AV1 können.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<SprachAuswahl
|
||||||
|
name="Tonspuren"
|
||||||
|
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
|
||||||
|
wert={werte['audioSprachen'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('audioSprachen', w)}
|
||||||
|
/>
|
||||||
|
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
||||||
|
</div>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const an = werte['automatik'] !== 'aus'
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="automatik"
|
||||||
|
titel="AUTOMATIK"
|
||||||
|
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
|
||||||
|
>
|
||||||
|
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
|
||||||
|
<label className="block max-w-xs">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Countdown</span>
|
||||||
|
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} disabled={!an} className={FELD_KLASSE}>
|
||||||
|
<option value="10">10 Sekunden</option>
|
||||||
|
<option value="30">30 Sekunden (Vorgabe)</option>
|
||||||
|
<option value="60">1 Minute</option>
|
||||||
|
<option value="120">2 Minuten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Der Countdown steht in der Laufwerks-Kachel, mit „Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
|
||||||
|
</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const v = verbindung.vorgaenge
|
||||||
|
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
||||||
|
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
||||||
|
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
|
||||||
|
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
|
||||||
|
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
|
||||||
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
|
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
||||||
|
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
||||||
|
rippt: 'rippt',
|
||||||
|
wartet: 'wartet auf Kompression',
|
||||||
|
komprimiert: 'komprimiert',
|
||||||
|
'legt-ab': 'legt ab',
|
||||||
|
fertig: 'fertig',
|
||||||
|
fehler: 'Fehler',
|
||||||
|
abgebrochen: 'abgebrochen',
|
||||||
|
}
|
||||||
|
const liste = v?.vorgaenge ?? []
|
||||||
|
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="roh"
|
||||||
|
titel="ROH-DATEIEN"
|
||||||
|
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
|
||||||
|
<select value={regelWert} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="sofort">sofort löschen</option>
|
||||||
|
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
|
||||||
|
<option value="14">nach 14 Tagen löschen</option>
|
||||||
|
<option value="30">nach 30 Tagen löschen</option>
|
||||||
|
<option value="behalten">behalten — nur von Hand löschen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
|
||||||
|
Jetzt aufräumen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
{v === null ? (
|
||||||
|
<p>Belegung wird gemessen …</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
|
||||||
|
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
|
||||||
|
</p>
|
||||||
|
<p>{v.regel}</p>
|
||||||
|
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.</p>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sichtbar.map((e) => {
|
||||||
|
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span className="font-display text-[14px] font-bold text-schnee">
|
||||||
|
{e.titel}
|
||||||
|
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
|
||||||
|
</span>
|
||||||
|
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
|
||||||
|
<span className="font-mono text-[10.5px] text-nebel">
|
||||||
|
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
|
||||||
|
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
|
||||||
|
{e.preset.length > 0 && ` · ${e.preset}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
|
||||||
|
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
|
||||||
|
{' · '}
|
||||||
|
<span className="text-dunst">Fertig:</span>{' '}
|
||||||
|
{e.phase === 'fertig' ? (
|
||||||
|
<span className="break-all">
|
||||||
|
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
|
||||||
|
{e.zielOrdner}
|
||||||
|
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn">⚠ {e.fehler}</p>}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{e.neuKomprimierbar && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={presetWahl[e.id] ?? ''}
|
||||||
|
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
|
||||||
|
className="rounded-lg border border-tinte/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
|
||||||
|
title="Preset für die Wiederholung — leer: wie eingestellt"
|
||||||
|
>
|
||||||
|
<option value="">Preset wie eingestellt</option>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
|
||||||
|
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
|
||||||
|
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
|
||||||
|
>
|
||||||
|
Neu komprimieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
|
||||||
|
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
|
||||||
|
Roh-Dateien löschen …
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
|
||||||
|
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
vorgangRohLoeschen(e.id)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja, löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
||||||
|
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||||
|
Ordner öffnen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
|
||||||
|
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
|
||||||
|
Kompression abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{liste.length > sichtbar.length && (
|
||||||
|
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||||
|
▸ alle {liste.length} Vorgänge zeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{v !== null && v.fremde.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
|
||||||
|
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
|
||||||
|
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{v.fremde.map((f) => (
|
||||||
|
<div key={f.ordner} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
||||||
|
{f.ordner} · {gb(f.bytes)}
|
||||||
|
</span>
|
||||||
|
{loeschfrage !== f.ordner ? (
|
||||||
|
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
||||||
|
Löschen …
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
|
||||||
|
wirklich?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
fremdenOrdnerLoeschen(f.ordner)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="metadaten"
|
||||||
|
titel="METADATEN"
|
||||||
|
intro="TMDb erkennt Titel, Jahr, Poster, Logo und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
www.themoviedb.org → Einstellungen → API
|
||||||
|
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
www.omdbapi.com/apikey.aspx
|
||||||
|
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" an der Laufwerks-Kachel bleibt ohne Suche.
|
||||||
|
</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const s = verbindung.schluessel
|
||||||
|
const b = verbindung.beschaffung
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="makemkv"
|
||||||
|
titel="MAKEMKV"
|
||||||
|
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3">
|
||||||
|
{s === null ? (
|
||||||
|
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 5.7.0 (F1/F5): Ein abgelehnter oder fehlender Schlüssel ist
|
||||||
|
ROT — Blu-rays laufen dann nur noch in MakeMKVs Gnadenfrist.
|
||||||
|
„Nicht prüfbar" (kein Laufwerk) ist grau, kein Erfolg. */}
|
||||||
|
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.angenommen === null ? 'bg-tinte/25' : 'bg-rot'}`} />
|
||||||
|
<span className={s.angenommen === true ? 'text-gruen' : s.angenommen === null ? 'text-nebel' : 'text-rot'}>
|
||||||
|
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.angenommen === null ? 'NICHT PRÜFBAR — KEIN LAUFWERK' : 'KEIN GÜLTIGER SCHLÜSSEL — BLU-RAY-RIPS LAUFEN NICHT'}
|
||||||
|
</span>
|
||||||
|
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
|
zuletzt geprüft: {datumKurz(s.geholtAm)}
|
||||||
|
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{s !== null && s.angenommen === false && (
|
||||||
|
<button
|
||||||
|
onClick={() => makemkvUpdate()}
|
||||||
|
disabled={b !== null && b.laeuft}
|
||||||
|
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
|
||||||
|
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
||||||
|
>
|
||||||
|
MakeMKV aktualisieren …
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
|
||||||
|
Schlüssel neu prüfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{b !== null && (
|
||||||
|
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
||||||
|
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||||
|
{b.frage !== undefined && b.frage.length > 0 && (
|
||||||
|
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
|
||||||
|
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
|
||||||
|
Archiv-Kopie laden
|
||||||
|
</button>
|
||||||
|
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
|
||||||
|
Lieber nicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{b.laeuft && b.anteil !== null && (
|
||||||
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
||||||
|
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
name="Eigener Schlüssel (Dauerlizenz)"
|
||||||
|
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
|
||||||
|
geheim
|
||||||
|
wert={werte['makemkvAppKey'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Rippy schreibt in MakeMKVs eigene Einstellungen: den Schlüssel, die Auswahlregel „alle Spuren behalten" und den Kanal für 4K-Disc-Schlüssel. Deshalb nimmt auch das MakeMKV-Programm seitdem alle Tonspuren und Untertitel mit — die Dateien dort werden größer, verloren geht nichts.
|
||||||
|
</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="medienserver"
|
||||||
|
titel="MEDIENSERVER"
|
||||||
|
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-semibold text-dunst">System</span>
|
||||||
|
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="none">keiner</option>
|
||||||
|
<option value="jellyfin">Jellyfin</option>
|
||||||
|
<option value="emby">Emby</option>
|
||||||
|
<option value="kodi">Kodi</option>
|
||||||
|
<option value="plex">Plex (nur Benennung)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
||||||
|
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur aus „Ablage" passt für alle vier.</p>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="benachrichtigung"
|
||||||
|
titel="BENACHRICHTIGUNG"
|
||||||
|
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht — über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
|
||||||
|
>
|
||||||
|
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
|
||||||
|
<Feld
|
||||||
|
name="Discord-Webhook"
|
||||||
|
hinweis="Discord → Servereinstellungen → Integrationen → Webhooks → URL kopieren"
|
||||||
|
geheim
|
||||||
|
wert={werte['discordWebhook'] ?? ''}
|
||||||
|
aufAendern={(w) => setzen('discordWebhook', w)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
|
||||||
|
<Feld name="Telegram-Chat-ID" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
||||||
|
Testnachricht schicken
|
||||||
|
</button>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const kern = verbindung.kern
|
||||||
|
const db = kern?.datenbank ?? null
|
||||||
|
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
|
||||||
|
return (
|
||||||
|
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||||
|
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Titel lesen — ohne Lebenszeichen höchstens</span>
|
||||||
|
<select value={werte['titelLesenMinuten'] ?? '15'} onChange={(e) => setzen('titelLesenMinuten', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="5">5 Minuten</option>
|
||||||
|
<option value="10">10 Minuten</option>
|
||||||
|
<option value="15">15 Minuten (Vorgabe)</option>
|
||||||
|
<option value="30">30 Minuten</option>
|
||||||
|
<option value="60">60 Minuten</option>
|
||||||
|
</select>
|
||||||
|
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV still sein (kein Fortschritt, keine Meldung), bevor Rippy das Titel-Lesen beendet. Solange MakeMKV arbeitet und Fortschritt meldet — etwa beim Durchlesen einer DVD mit beschädigter IFO-Datei, was 20–60 Minuten dauern kann — wartet Rippy; „Abbrechen" im Dialog geht jederzeit.</span>
|
||||||
|
</label>
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
||||||
|
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="dunkel">Dunkel — der Kinosaal (Vorgabe)</option>
|
||||||
|
<option value="hell">Hell — Papier und Gold</option>
|
||||||
|
<option value="system">Wie Windows eingestellt</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben — Haupt und Kern schreiben hinein">
|
||||||
|
Protokoll öffnen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
|
||||||
|
Ablage im Explorer
|
||||||
|
</button>
|
||||||
|
{(werte['arbeitsordner'] ?? '').length > 0 && (
|
||||||
|
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
|
||||||
|
Arbeitsordner im Explorer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
<p>
|
||||||
|
Rippy v{haupt?.version ?? '…'}
|
||||||
|
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
|
||||||
|
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
|
||||||
|
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
|
||||||
|
</p>
|
||||||
|
<p className="break-all">Datenbank: {dbText}</p>
|
||||||
|
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||||
|
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
||||||
|
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
||||||
|
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||||
|
return (
|
||||||
|
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea — geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
|
||||||
|
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
||||||
|
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
||||||
|
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
||||||
|
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
||||||
|
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
||||||
|
https://). */}
|
||||||
|
{haupt !== null && (
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
|
<p>
|
||||||
|
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
||||||
|
</p>
|
||||||
|
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {datumKurz(haupt.update.letzteErfolgreichePruefung)}</p>}
|
||||||
|
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void window.rippy.updatePruefen()}
|
||||||
|
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
|
||||||
|
className={KNOPF_LEISE}
|
||||||
|
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
|
||||||
|
>
|
||||||
|
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WerkzeugZeile({
|
||||||
|
name,
|
||||||
|
pfad,
|
||||||
|
fehlt,
|
||||||
|
pflicht,
|
||||||
|
schluessel,
|
||||||
|
wert,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
pfad: string | null
|
||||||
|
fehlt: string
|
||||||
|
pflicht: boolean
|
||||||
|
schluessel: string
|
||||||
|
wert: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-tinte/8 bg-samt/50 p-3">
|
||||||
|
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||||
|
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
|
||||||
|
</p>
|
||||||
|
<PfadFeld
|
||||||
|
name="Eigener Pfad"
|
||||||
|
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
|
||||||
|
wert={wert}
|
||||||
|
art="programm"
|
||||||
|
dialogTitel={`${name} wählen`}
|
||||||
|
aufAendern={(w) => setzen(schluessel, w)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||||
|
const werte = verbindung.einstellungen
|
||||||
|
const backends = verbindung.werkzeuge?.backends ?? []
|
||||||
|
return (
|
||||||
|
<SektionsKarte
|
||||||
|
id="werkzeuge"
|
||||||
|
titel="WERKZEUGE"
|
||||||
|
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
|
||||||
|
>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="makemkvcon"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
|
||||||
|
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
|
||||||
|
pflicht={true}
|
||||||
|
schluessel="werkzeug.makemkv"
|
||||||
|
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||||
|
/>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="HandBrakeCLI"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
|
||||||
|
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
|
||||||
|
pflicht={false}
|
||||||
|
schluessel="werkzeug.handbrake"
|
||||||
|
wert={werte['werkzeug.handbrake'] ?? ''}
|
||||||
|
/>
|
||||||
|
<WerkzeugZeile
|
||||||
|
name="flac"
|
||||||
|
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
|
||||||
|
fehlt="nicht gefunden — nur für Audio-CDs nötig"
|
||||||
|
pflicht={false}
|
||||||
|
schluessel="werkzeug.flac"
|
||||||
|
wert={werte['werkzeug.flac'] ?? ''}
|
||||||
|
/>
|
||||||
|
{verbindung.werkzeuge !== null && (
|
||||||
|
<p className="font-mono text-[10.5px] text-nebel">
|
||||||
|
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SektionsKarte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
|
||||||
|
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
|
||||||
|
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
|
||||||
|
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
|
||||||
|
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
|
||||||
|
|
||||||
|
export function SprachAuswahl({
|
||||||
|
name,
|
||||||
|
hinweis,
|
||||||
|
wert,
|
||||||
|
aufAendern,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
hinweis: string
|
||||||
|
wert: string
|
||||||
|
aufAendern: (wert: string) => void
|
||||||
|
}) {
|
||||||
|
const gewaehlt = sprachlisteAus(wert)
|
||||||
|
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
|
||||||
|
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
|
||||||
|
const verschieben = (code: string, richtung: -1 | 1): void => {
|
||||||
|
const i = gewaehlt.indexOf(code)
|
||||||
|
const j = i + richtung
|
||||||
|
if (i < 0 || j < 0 || j >= gewaehlt.length) return
|
||||||
|
const neu = [...gewaehlt]
|
||||||
|
;[neu[i], neu[j]] = [neu[j], neu[i]]
|
||||||
|
setzen(neu)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||||
|
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
|
||||||
|
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-tinte/12 bg-samt px-2 py-1.5">
|
||||||
|
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
|
||||||
|
{gewaehlt.map((code, i) => (
|
||||||
|
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||||||
|
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
|
||||||
|
<span>{sprachName(code)}</span>
|
||||||
|
<span className="font-mono text-[10px] text-nebel">{code}</span>
|
||||||
|
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
|
||||||
|
}}
|
||||||
|
className="ml-auto rounded-md border border-tinte/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
|
||||||
|
>
|
||||||
|
<option value="">+ Sprache …</option>
|
||||||
|
{frei.map((s) => (
|
||||||
|
<option key={s.code} value={s.code}>
|
||||||
|
{s.name} ({s.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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>
|
||||||
|
dateiWaehlen(titel: string): Promise<string | null>
|
||||||
|
ordnerOeffnen(pfad: string): Promise<string>
|
||||||
|
protokollOeffnen(): Promise<string>
|
||||||
|
autostartSetzen(an: boolean): Promise<boolean>
|
||||||
|
updatePruefen(): Promise<unknown>
|
||||||
|
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
|
||||||
|
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): 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>,
|
||||||
|
)
|
||||||