Compare commits
72
Commits
a5e64f8e56
...
aktuell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b | ||
|
|
6881a63fb7 | ||
|
|
35370fd552 | ||
|
|
11001a443e | ||
|
|
be3fac53af | ||
|
|
878b24c43b | ||
|
|
ea8a043643 | ||
|
|
95beb91364 | ||
|
|
480b658294 | ||
|
|
16df61ea3a | ||
|
|
3d5f82b786 | ||
|
|
8e65411189 | ||
|
|
082e8b8b55 | ||
|
|
6dfff5c99d | ||
|
|
8eca982e68 | ||
|
|
151376aee3 | ||
|
|
b47d21510d | ||
|
|
a4a045f303 | ||
|
|
66642b8d93 |
@@ -52,6 +52,17 @@ jobs:
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
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
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
|
||||
@@ -55,3 +55,6 @@ logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
dist/
|
||||
|
||||
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||
design-entwuerfe/
|
||||
|
||||
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
(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
|
||||
|
||||
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
|
||||
|
||||
+104
@@ -1362,8 +1362,112 @@ Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig.
|
||||
`vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem
|
||||
Klon dabei und bei jedem Update ein zweites Mal.
|
||||
|
||||
|
||||
### Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt
|
||||
|
||||
Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt:
|
||||
|
||||
> „Du hast ja quasi nur rippy genommen und die docker installation für Windows
|
||||
> gebaut. […] Das gilt für die ganze standalone version für Windows, auch für
|
||||
> die settings und die Anleitung usw."
|
||||
|
||||
> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert —
|
||||
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
|
||||
> garnichts Rippy geht einfach auf."
|
||||
|
||||
#### 6a — Fähigkeiten statt Modus-Name
|
||||
|
||||
Im Windows-Fenster stand `Worker erreichbar: 0 von 1`, `Container-Platte:
|
||||
unbekannt` und `Prüfen: docker compose ps`. Kein Satz davon ergibt dort einen
|
||||
Sinn.
|
||||
|
||||
Die naheliegende Reparatur wäre ein `if (windows)` an dreißig Stellen gewesen
|
||||
— dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es
|
||||
fehlte etwas anderes: **Das UI hat nie erfahren, worauf es läuft.**
|
||||
|
||||
`GET /betrieb` meldet deshalb **Fähigkeiten**, keinen Namen:
|
||||
|
||||
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
|
||||
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
|
||||
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
|
||||
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
|
||||
|
||||
Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu
|
||||
schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One
|
||||
hat Container-Pfade, aber keinen zweiten Worker.
|
||||
|
||||
#### 6b — Ein Setup, das prüft und fragt
|
||||
|
||||
Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm **und** Ablage),
|
||||
Port, drei Schalter. Drei Regeln:
|
||||
|
||||
1. **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist eine
|
||||
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches
|
||||
Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein
|
||||
vorgesehener Betriebsfall.
|
||||
2. **Jeder Befund sagt, was zu tun ist.** Ein Test hält das für jede Prüfung
|
||||
fest.
|
||||
3. **Die Prüfungen kennen kein Fenster.** Sie stehen in `einrichtung.py` und
|
||||
sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau
|
||||
und Brücke.
|
||||
|
||||
WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der
|
||||
Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem
|
||||
Netz; sie muss auf einem Rechner ohne Internet aufgehen.
|
||||
|
||||
|
||||
#### 5a — Ausweichquellen für MakeMKV (28.08.2026, nachgetragen)
|
||||
|
||||
Der Commander: *„bitte baue für MakeMKV fallback seiten ein."* Anlass war ein
|
||||
echter Ausfall. Gemessen:
|
||||
|
||||
https://www.makemkv.com/download/ HTTP 525 (dauerhaft)
|
||||
https://forum.makemkv.com/ Zeitablauf / 522 / OK (wechselnd)
|
||||
web.archive.org/web/2025id_/…exe HTTP 200, 16.432.607 Bytes
|
||||
|
||||
525/522 heißen: Cloudflare erreicht den Ursprungsserver nicht. Eine Störung
|
||||
beim Hersteller, nichts, was Rippy reparieren könnte.
|
||||
|
||||
**Die Kette, in dieser Reihenfolge:**
|
||||
|
||||
| | Version | Datei |
|
||||
|---|---|---|
|
||||
| eigene Quelle (`basis`) | — | zuerst, wer sie einträgt hat sich etwas dabei gedacht |
|
||||
| Hersteller-Seite | **maßgeblich** — wer sie erreicht, sucht nicht weiter | ja |
|
||||
| Hersteller-Forum | ja (Ankündigungs-Bereich) | — |
|
||||
| Internet Archive | ja (Rückfall) | ja |
|
||||
|
||||
Zwei Feinheiten, beide aus Messungen:
|
||||
|
||||
* **Die höchste Nummer gewinnt, nicht die erste.** Antwortet das Forum nicht,
|
||||
meldet das Archiv einen älteren Stand (1.18.2 statt 1.18.4) — „neueste
|
||||
Fassung" wäre dann eine falsche Aussage.
|
||||
* **Ein zweiter Anlauf, mit knapper Zeitgrenze.** Drei Abrufe am Forum:
|
||||
Zeitablauf, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in
|
||||
zwei von drei Fällen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste
|
||||
Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt: acht
|
||||
Sekunden je Versuch, schlimmstenfalls gut zwanzig insgesamt.
|
||||
|
||||
**Und warum das trotzdem sicher ist.** Der naheliegende Schutz geht nicht:
|
||||
MakeMKV **signiert seinen Installer nicht** (`Get-AuthenticodeSignature` →
|
||||
`NotSigned`, an der echten Datei gemessen). Eine Signaturprüfung wäre eine,
|
||||
die immer fehlschlägt. Geprüft wird stattdessen die Versions-Ressource:
|
||||
|
||||
CompanyName GuinpinSoft inc
|
||||
FileDescription MakeMKV installer
|
||||
FileVersion v1.18.4
|
||||
|
||||
Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die
|
||||
Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine
|
||||
Fehlerseite mit `.exe`-Namen, ein abgebrochener Download, eine falsche
|
||||
Fassung. Und Rippy nennt hinterher die Quelle, aus der die Datei kam.
|
||||
|
||||
**Die Grenze aus `KONZEPT.md` § 6 bleibt unangetastet:** Rippy liefert
|
||||
MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers — im
|
||||
Rückfall aus einem Archiv, das genau diese Datei aufbewahrt.
|
||||
---
|
||||
|
||||
|
||||
### Was jetzt noch fehlt, bevor gebaut wird
|
||||
|
||||
- **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom
|
||||
|
||||
+1131
File diff suppressed because it is too large
Load Diff
@@ -639,6 +639,14 @@ Medien.
|
||||
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
|
||||
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
|
||||
ein leeres Blatt
|
||||
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
|
||||
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
|
||||
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
|
||||
Doppelklick stillschweigend und öffnete das Fenster.
|
||||
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
|
||||
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
|
||||
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
|
||||
behält alles.
|
||||
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
|
||||
— **offen**
|
||||
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
|
||||
|
||||
+1229
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
|
||||
|
||||
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
|
||||
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
|
||||
Konzept hängt:
|
||||
|
||||
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
|
||||
|
||||
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
|
||||
sie zuerst gelaufen — vor der ersten Zeile Konzept.
|
||||
|
||||
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
|
||||
|
||||
---
|
||||
|
||||
## Nachstellen
|
||||
|
||||
```
|
||||
npm install koffi
|
||||
node laufwerk.js
|
||||
```
|
||||
|
||||
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
|
||||
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
|
||||
|
||||
---
|
||||
|
||||
## `laufwerk.js` — Win32-Laufwerkszugriff
|
||||
|
||||
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
|
||||
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
|
||||
|
||||
```
|
||||
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
|
||||
2. CreateFileW \\.\G: (Zugriff 0) -> offen
|
||||
CreateFileW \\.\G: (GENERIC_READ) -> offen
|
||||
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
|
||||
| Rev: 1.03 | Serial: 0025114C0149
|
||||
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
|
||||
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
|
||||
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
|
||||
```
|
||||
|
||||
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
|
||||
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
|
||||
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
|
||||
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
|
||||
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
|
||||
|
||||
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
|
||||
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
|
||||
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
|
||||
ein antwortendes Laufwerk für tot.
|
||||
|
||||
---
|
||||
|
||||
## `leine.js` — die Prozess-Leine (Job Object)
|
||||
|
||||
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
|
||||
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
|
||||
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
|
||||
|
||||
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
|
||||
startet ein langlebiges Kind und lässt sich dann hart abschießen —
|
||||
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
|
||||
Drüber-Installieren passiert.
|
||||
|
||||
```
|
||||
1. CreateJobObjectW -> Arbeitsgruppe angelegt
|
||||
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
|
||||
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
|
||||
4. Kindprozess gestartet, PID 16040
|
||||
|
||||
vor dem Abschuss: Eltern lebt: True Kind lebt: True
|
||||
taskkill /F /PID 29488 -> ERFOLGREICH
|
||||
danach: Eltern lebt: False Kind lebt: False
|
||||
```
|
||||
|
||||
Nachstellen (PowerShell):
|
||||
|
||||
```powershell
|
||||
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
|
||||
Start-Sleep 3
|
||||
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
|
||||
taskkill /F /PID $p.Id
|
||||
Start-Sleep 2
|
||||
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
|
||||
|
||||
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
|
||||
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
|
||||
überhaupt vorhanden?
|
||||
|
||||
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
|
||||
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
|
||||
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
|
||||
|
||||
```
|
||||
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
|
||||
Kern beendet mit 0
|
||||
```
|
||||
|
||||
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
|
||||
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
|
||||
Messung zuvor ebenfalls grün.)
|
||||
|
||||
Nachstellen:
|
||||
|
||||
```
|
||||
npm install electron@44 --no-save
|
||||
node node_modules/electron/install.js
|
||||
./node_modules/electron/dist/electron.exe sqlite-main.js
|
||||
```
|
||||
|
||||
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
|
||||
|
||||
---
|
||||
|
||||
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
|
||||
|
||||
```
|
||||
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
|
||||
```
|
||||
|
||||
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
|
||||
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
|
||||
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
|
||||
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
|
||||
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
|
||||
Tages jemand an der falschen Stelle.
|
||||
@@ -0,0 +1,109 @@
|
||||
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
|
||||
// NUR LESEND — kein Auswerfen, kein Verriegeln.
|
||||
const koffi = require('koffi');
|
||||
|
||||
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
|
||||
const ctl = (typ, fn, methode, zugriff) =>
|
||||
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
|
||||
|
||||
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
|
||||
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
|
||||
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
|
||||
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
|
||||
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
|
||||
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
|
||||
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
|
||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
|
||||
|
||||
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
|
||||
const DRIVE_CDROM = 5;
|
||||
|
||||
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
|
||||
const maske = GetLogicalDrives();
|
||||
const optische = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (!(maske & (1 << i))) continue;
|
||||
const buchstabe = String.fromCharCode(65 + i);
|
||||
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
|
||||
}
|
||||
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
|
||||
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const lw = optische[0];
|
||||
|
||||
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
|
||||
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
|
||||
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
|
||||
function oeffnen(zugriff) {
|
||||
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
|
||||
const adr = koffi.address(h);
|
||||
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
|
||||
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
|
||||
return { h, fehler: 0 };
|
||||
}
|
||||
|
||||
const ohneMedium = oeffnen(0);
|
||||
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
|
||||
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
|
||||
const mitLesen = oeffnen(GENERIC_READ);
|
||||
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
|
||||
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
|
||||
|
||||
const h = ohneMedium.h;
|
||||
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
|
||||
|
||||
const rueck = Buffer.alloc(4);
|
||||
|
||||
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
|
||||
const anfrage = Buffer.alloc(12);
|
||||
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
|
||||
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
|
||||
const antwort = Buffer.alloc(1024);
|
||||
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
|
||||
const text = (off) => {
|
||||
const start = antwort.readUInt32LE(off);
|
||||
if (!start || start >= antwort.length) return '';
|
||||
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
|
||||
return antwort.toString('latin1', start, ende).trim();
|
||||
};
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
|
||||
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
|
||||
} else {
|
||||
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
|
||||
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
|
||||
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
|
||||
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
|
||||
|
||||
// ── 5. Audio- oder Datenspur?
|
||||
const typBuf = Buffer.alloc(1);
|
||||
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
|
||||
const t = typBuf[0];
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
|
||||
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
|
||||
} else {
|
||||
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
|
||||
const laenge = Buffer.alloc(8);
|
||||
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
|
||||
const bytes = laenge.readBigUInt64LE(0);
|
||||
const gb = Number(bytes) / 1e9;
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
|
||||
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
|
||||
} else {
|
||||
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
|
||||
}
|
||||
|
||||
CloseHandle(h);
|
||||
if (mitLesen.h) CloseHandle(mitLesen.h);
|
||||
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
|
||||
@@ -0,0 +1,50 @@
|
||||
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
|
||||
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
|
||||
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
|
||||
// makemkvcon Rippy und haelt das Laufwerk fest.
|
||||
const koffi = require('koffi');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll');
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
|
||||
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
|
||||
|
||||
// JobObjectExtendedLimitInformation = 9 (winnt.h)
|
||||
const JobObjectExtendedLimitInformation = 9;
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
|
||||
const job = CreateJobObjectW(null, null);
|
||||
if (koffi.address(job) === 0n) {
|
||||
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
|
||||
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
|
||||
const info = Buffer.alloc(144);
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
|
||||
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
|
||||
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
|
||||
|
||||
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
|
||||
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
|
||||
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
|
||||
console.log('ELTERN-PID=' + process.pid);
|
||||
console.log('KIND-PID=' + kind.pid);
|
||||
|
||||
// Offen halten, bis uns jemand hart abschiesst.
|
||||
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
|
||||
@@ -0,0 +1,12 @@
|
||||
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
|
||||
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
|
||||
try {
|
||||
const s = require('node:sqlite');
|
||||
const db = new s.DatabaseSync(':memory:');
|
||||
db.exec('CREATE TABLE t (a TEXT)');
|
||||
db.prepare('INSERT INTO t VALUES (?)').run('geht');
|
||||
const wert = db.prepare('SELECT a FROM t').get().a;
|
||||
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
|
||||
} catch (e) {
|
||||
process.parentPort.postMessage({ ok: false, fehler: e.message });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
|
||||
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
|
||||
//
|
||||
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
|
||||
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
|
||||
const { app, utilityProcess } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
|
||||
kind.on('message', (antwort) => {
|
||||
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
|
||||
app.exit(antwort.ok ? 0 : 1);
|
||||
});
|
||||
kind.on('exit', (code) => {
|
||||
console.log('Kern beendet mit ' + code);
|
||||
if (code !== 0) app.exit(1);
|
||||
});
|
||||
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Vendored
+98
-18
@@ -1,42 +1,122 @@
|
||||
"""Cache module."""
|
||||
"""Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt.
|
||||
|
||||
## Warum das nicht mehr scheitern darf (Befund 28.08.2026)
|
||||
|
||||
Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis`
|
||||
— der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem
|
||||
Windows-PC gibt es kein Redis, und jeder Aufruf endete mit:
|
||||
|
||||
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
|
||||
|
||||
Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache:
|
||||
Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an.
|
||||
|
||||
**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist
|
||||
keiner da, wird eben jedes Mal neu gefragt — TMDB und OMDb halten das aus.
|
||||
Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als
|
||||
wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es
|
||||
nicht.
|
||||
|
||||
## Warum trotzdem einmal gemeldet wird
|
||||
|
||||
Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare
|
||||
Verlangsamung — und auf der VM wäre ein weggefallenes Redis ein echter
|
||||
Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene
|
||||
Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre
|
||||
Lärm.
|
||||
"""
|
||||
|
||||
from redis import Redis
|
||||
from typing import Any, Optional
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
redis_host = os.getenv("REDIS_HOST", "redis")
|
||||
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:
|
||||
"""Initialize cache connection."""
|
||||
pass
|
||||
"""Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie."""
|
||||
global _erreichbar
|
||||
try:
|
||||
redis_client.ping()
|
||||
_erreichbar = True
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
|
||||
|
||||
def get(key: str) -> Optional[Any]:
|
||||
"""Get value from cache."""
|
||||
value = redis_client.get(key)
|
||||
"""Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher."""
|
||||
global _erreichbar
|
||||
try:
|
||||
value = redis_client.get(key)
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return None
|
||||
_erreichbar = True
|
||||
if value:
|
||||
return json.loads(value)
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
# Ein kaputter Eintrag ist kein Grund, den Aufrufer scheitern zu
|
||||
# lassen — er holt sich die Antwort dann eben frisch.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
|
||||
"""Set value in cache."""
|
||||
serialized = json.dumps(value)
|
||||
if expire:
|
||||
return redis_client.setex(key, expire, serialized)
|
||||
return redis_client.set(key, serialized)
|
||||
"""Wert ablegen. False heißt: ging nicht — und das ist in Ordnung."""
|
||||
global _erreichbar
|
||||
try:
|
||||
serialized = json.dumps(value)
|
||||
if expire:
|
||||
return bool(redis_client.setex(key, expire, serialized))
|
||||
return bool(redis_client.set(key, serialized))
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# Nicht serialisierbar. Das ist ein Fehler des Aufrufers, aber keiner,
|
||||
# der eine Metadaten-Abfrage umwerfen darf.
|
||||
return False
|
||||
|
||||
|
||||
def delete(key: str) -> bool:
|
||||
"""Delete value from cache."""
|
||||
return redis_client.delete(key)
|
||||
try:
|
||||
return bool(redis_client.delete(key))
|
||||
except (RedisError, OSError) as e:
|
||||
_melde_einmal(e)
|
||||
return False
|
||||
|
||||
|
||||
def clear() -> bool:
|
||||
"""Clear all cache."""
|
||||
return redis_client.flushdb()
|
||||
try:
|
||||
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:
|
||||
def __init__(self):
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||
# 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()
|
||||
|
||||
def suche(self, title: str) -> list:
|
||||
|
||||
@@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
|
||||
class TheTVDBClient:
|
||||
def __init__(self):
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
|
||||
# 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.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
|
||||
@@ -24,8 +24,20 @@ class TMDBClient:
|
||||
def __init__(self):
|
||||
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
|
||||
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
|
||||
from db import get_settings
|
||||
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
|
||||
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
|
||||
#
|
||||
# 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.headers.update({"Content-Type": "application/json"})
|
||||
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
|
||||
|
||||
+42
-5
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
|
||||
return f"eta:{job_id}"
|
||||
|
||||
|
||||
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||||
#:
|
||||
#: ## Der Befund des Commanders (29.08.2026)
|
||||
#:
|
||||
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||
#:
|
||||
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||||
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||||
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||||
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||||
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||||
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||||
#:
|
||||
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||||
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||||
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||||
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||||
_REIHEN: dict = {}
|
||||
|
||||
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||||
REIHE_HALTBAR_SEKUNDEN = 86400
|
||||
|
||||
|
||||
def _aufraeumen(jetzt: float) -> None:
|
||||
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||||
for k, (stand, _) in list(_REIHEN.items()):
|
||||
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||||
_REIHEN.pop(k, None)
|
||||
|
||||
|
||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||
jetzt: float, cache_get, cache_set) -> dict:
|
||||
"""Messreihe 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
|
||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
||||
keine ETA, aber nichts scheitert.
|
||||
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||
"""
|
||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||
return {"sekunden": -1, "text": ""}
|
||||
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||
reihe = cache_get(k)
|
||||
except Exception:
|
||||
reihe = None
|
||||
if not reihe:
|
||||
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||||
eintrag = _REIHEN.get(k)
|
||||
reihe = eintrag[1] if eintrag else None
|
||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||
try:
|
||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||
cache_set(k, reihe, expire=86400)
|
||||
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||
except Exception:
|
||||
pass
|
||||
_REIHEN[k] = (jetzt, reihe)
|
||||
_aufraeumen(jetzt)
|
||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||
|
||||
+797
-92
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
|
||||
ziel = _mountpoint(name)
|
||||
try:
|
||||
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
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
||||
"""
|
||||
try:
|
||||
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):
|
||||
return "unklar"
|
||||
|
||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
||||
NICHTS = ""
|
||||
|
||||
|
||||
def _meta(meta_json) -> dict:
|
||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
||||
def meta_von(meta_json) -> dict:
|
||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||
|
||||
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||
"""
|
||||
if isinstance(meta_json, dict):
|
||||
return meta_json
|
||||
if not meta_json:
|
||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
||||
"""
|
||||
if (job.get("status") or "") != "failed":
|
||||
return NICHTS
|
||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
||||
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||
if marke is True:
|
||||
return NEU_KOMPRIMIEREN
|
||||
if marke is False:
|
||||
|
||||
+173
-54
@@ -6,11 +6,11 @@ lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from rippy import drives as _laufwerks_schicht
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from clients.tmdb import TMDBClient
|
||||
from clients.jikan import JikanClient
|
||||
from clients.musicbrainz import MusicBrainzClient
|
||||
@@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key
|
||||
# test_treiberwahl.py.
|
||||
detection = _laufwerks_schicht.treiber()
|
||||
|
||||
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
|
||||
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
|
||||
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
|
||||
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
|
||||
# Neuauflagen und Regie-Fassungen desselben Films.
|
||||
WENIGE_TREFFER = 3
|
||||
|
||||
def normalize_disc_label(label: str) -> str:
|
||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||
|
||||
@@ -80,50 +87,95 @@ def parse_udf_dstring(data: bytes) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def disc_wurzel(device_path: str):
|
||||
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
|
||||
|
||||
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
|
||||
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
|
||||
|
||||
## Warum das getrennt ist (28.08.2026)
|
||||
|
||||
Der Weg hier war fest `mount -t udf` — also Linux. Unter Windows haengt
|
||||
Windows die Disc selbst ein: `\.\G:` entspricht `G:\`, und dort liegen
|
||||
die Dateien einfach da. Ohne diese Unterscheidung fand Rippy unter
|
||||
Windows NIE den Klartext-Titel und blieb beim Volume-Label haengen
|
||||
(gemessen an einer echten Disc: `BD_EVG_D2` statt `Evangelion 2.22`) --
|
||||
und mit so einem Label findet keine Metadaten-API etwas.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
# \.\G: -> G:\ (der Buchstabe ist alles, was wir brauchen)
|
||||
buchstabe = device_path.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
||||
wurzel = buchstabe + ":" + "\\"
|
||||
return (wurzel, lambda: None) if os.path.isdir(wurzel) else (None, lambda: None)
|
||||
|
||||
mountpoint = "/mnt/rippy-disc"
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
ergebnis = subprocess.run(
|
||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
return None, lambda: None
|
||||
|
||||
def abhaengen():
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
|
||||
creationflags=OHNE_FENSTER)
|
||||
|
||||
return mountpoint, abhaengen
|
||||
|
||||
|
||||
def titel_aus_bdmt(wurzel: str) -> Optional[str]:
|
||||
"""Der Klartext-Titel aus BDMV/META/DL/bdmt_*.xml. (ohne Mounten)
|
||||
|
||||
Getrennt von der Beschaffung der Wurzel, damit er ohne Disc pruefbar ist:
|
||||
ein Ordner mit einer bdmt-Datei reicht.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
meta_dir = os.path.join(wurzel, "BDMV", "META", "DL")
|
||||
if not os.path.isdir(meta_dir):
|
||||
return None
|
||||
try:
|
||||
kandidaten = sorted(os.listdir(meta_dir))
|
||||
except OSError:
|
||||
return None
|
||||
# bdmt_eng.xml bevorzugen, sonst die erste bdmt-Datei (bdmt_deu.xml, ...)
|
||||
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
|
||||
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
||||
)
|
||||
if not bdmt:
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
# <di:name>Titel</di:name> - bewusst per Regex statt XML-Parser
|
||||
# (Namespaces variieren je Authoring-Werkzeug)
|
||||
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
||||
return treffer.group(1).strip() if treffer else None
|
||||
|
||||
|
||||
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
|
||||
"""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. Die API darf
|
||||
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).
|
||||
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).
|
||||
"""
|
||||
import re as _re
|
||||
mountpoint = "/mnt/rippy-disc"
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
gemountet = False
|
||||
wurzel, aufraeumen = None, lambda: None
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if ergebnis.returncode != 0:
|
||||
return None
|
||||
gemountet = True
|
||||
|
||||
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
|
||||
if not os.path.isdir(meta_dir):
|
||||
return None
|
||||
kandidaten = sorted(os.listdir(meta_dir))
|
||||
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
|
||||
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
|
||||
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
|
||||
)
|
||||
if not bdmt:
|
||||
return None
|
||||
with open(os.path.join(meta_dir, bdmt), "rb") as f:
|
||||
inhalt = f.read(65536).decode("utf-8", errors="replace")
|
||||
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
|
||||
# (Namespaces variieren je Authoring-Werkzeug)
|
||||
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
|
||||
if treffer:
|
||||
return treffer.group(1).strip()
|
||||
return None
|
||||
wurzel, aufraeumen = disc_wurzel(device_path)
|
||||
return titel_aus_bdmt(wurzel) if wurzel else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if gemountet:
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
||||
try:
|
||||
aufraeumen()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def titel_kandidaten(titel: str) -> List[str]:
|
||||
@@ -279,7 +331,8 @@ class PreScan:
|
||||
["cdparanoia", "-Q", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
timeout=10,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'track' in line.lower():
|
||||
@@ -303,22 +356,37 @@ class PreScan:
|
||||
if label:
|
||||
toc["title"] = normalize_disc_label(label)
|
||||
|
||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||
if shutil.which("makemkvcon"):
|
||||
result = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
if line.startswith('TINFO:'):
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 5:
|
||||
toc["tracks"].append({
|
||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
||||
})
|
||||
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||
#
|
||||
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||
#
|
||||
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||
# lange. Das ging mal viel schneller."
|
||||
#
|
||||
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||
#
|
||||
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||
# `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:
|
||||
print(f"Pre-Scan TOC Error: {e}")
|
||||
return toc
|
||||
@@ -395,11 +463,13 @@ class PreScan:
|
||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||
kandidaten = titel_kandidaten(title) or [title]
|
||||
movies = []
|
||||
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||
|
||||
for kandidat in kandidaten:
|
||||
treffer = self.tmdb.search_movie(kandidat)
|
||||
if treffer and not movies:
|
||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||
movies_kandidat = kandidat
|
||||
for movie in treffer:
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
confidence = 0.95
|
||||
@@ -446,6 +516,55 @@ class PreScan:
|
||||
if matched:
|
||||
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
|
||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||
#
|
||||
|
||||
+137
-15
@@ -38,18 +38,71 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||
"""
|
||||
|
||||
import posixpath
|
||||
import os
|
||||
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"
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
|
||||
def _ui_einstellungen() -> dict:
|
||||
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||
gerade nicht antwortet.
|
||||
|
||||
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||
Vorgabe, wie bisher.
|
||||
"""
|
||||
try:
|
||||
from rippy import store as db
|
||||
|
||||
return db.get_settings(bei_fehler_leer=True) or {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def wurzeln(werte=None) -> tuple:
|
||||
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||
|
||||
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||
|
||||
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||
`/app/media`.
|
||||
|
||||
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||
4K-UHD bis zu 100 GB.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
|
||||
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
|
||||
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
|
||||
# was eingestellt ist (Befund 30.08.2026).
|
||||
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
|
||||
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||
betrieb.frei_blaettern(werte))
|
||||
|
||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||
|
||||
|
||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> 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).
|
||||
|
||||
`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:
|
||||
return []
|
||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||||
wahl = (work_dir or "").strip().rstrip("/")
|
||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||||
orte.append(posixpath.join(wahl, job_id))
|
||||
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||
from rippy import pfade
|
||||
|
||||
orte = [pfade.verbinden(roh, job_id)]
|
||||
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
|
||||
#
|
||||
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
|
||||
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
|
||||
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
|
||||
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
|
||||
#
|
||||
# "F:\\".rstrip("/\\") -> "F:"
|
||||
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
|
||||
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
|
||||
#
|
||||
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
|
||||
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
|
||||
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
|
||||
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
|
||||
wahl = (work_dir or "").strip()
|
||||
vergleich = wahl.rstrip("/\\")
|
||||
grenze = (medien or "").rstrip("/\\")
|
||||
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||
if vergleich and (frei or vergleich == grenze
|
||||
or vergleich.startswith(grenze + "/")):
|
||||
orte.append(pfade.verbinden(wahl, job_id))
|
||||
for name in media_unterordner or []:
|
||||
if name:
|
||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
||||
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||
gesehen, eindeutig = set(), []
|
||||
for ort in orte:
|
||||
if ort not in gesehen:
|
||||
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||
return eindeutig
|
||||
|
||||
|
||||
def nativ_nachsehen() -> bool:
|
||||
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||
|
||||
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||
`pruefen`.
|
||||
"""
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def pruefen(pfad: str, laufen=None) -> str:
|
||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||
|
||||
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
||||
"""
|
||||
if not pfad:
|
||||
return "weg"
|
||||
if laufen is None and nativ_nachsehen():
|
||||
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||
#
|
||||
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||
#
|
||||
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||
# 14:54:40 WindowsTerminal.exe
|
||||
#
|
||||
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||
# gehen ganz kurz auf und dann wieder zu."
|
||||
#
|
||||
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||
# unter Windows grundsaetzlich unsichtbar.
|
||||
#
|
||||
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||
try:
|
||||
return "da" if os.path.isdir(pfad) else "weg"
|
||||
except OSError:
|
||||
return "unklar"
|
||||
starten = laufen or subprocess.run
|
||||
try:
|
||||
ergebnis = starten(
|
||||
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||
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.
|
||||
|
||||
`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
|
||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||
"""
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden = []
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||
try:
|
||||
if isdir(ort):
|
||||
gefunden.append(ort)
|
||||
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
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.
|
||||
|
||||
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()).
|
||||
"""
|
||||
pruefe = pruefer or pruefen
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
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)
|
||||
if antwort == "da":
|
||||
gefunden.append(ort)
|
||||
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||||
except OSError:
|
||||
continue
|
||||
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:
|
||||
if isfile(voll):
|
||||
bytes_gesamt += getsize(voll)
|
||||
|
||||
+609
-11
@@ -2,16 +2,20 @@
|
||||
|
||||
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
||||
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
||||
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
||||
wo auch die Ampel läuft.
|
||||
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
|
||||
|
||||
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
|
||||
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
|
||||
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
|
||||
Plattformen — nachgemessen: 57 Routen, sauberer Import.
|
||||
|
||||
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
|
||||
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
|
||||
Plattform greift, ist eine halbe Zusage — genau daran ist die Ampel am
|
||||
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
||||
import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
|
||||
|
||||
|
||||
def test_main_importierbar_und_routen_verdrahtet():
|
||||
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
|
||||
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
||||
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
|
||||
"/system/keystore",
|
||||
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
|
||||
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
|
||||
"/betrieb",
|
||||
):
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
@@ -317,9 +324,18 @@ def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
|
||||
assert zustand["jobs"] == []
|
||||
|
||||
|
||||
def test_snapshot_liefert_alle_vier_bereiche(monkeypatch):
|
||||
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
||||
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
|
||||
er den Rest raten und faellt auf Polling zurueck."""
|
||||
er den Rest raten und faellt auf Polling zurueck.
|
||||
|
||||
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
|
||||
|
||||
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
|
||||
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
|
||||
Viertelminute auf „Platz fuer Rippy: unbekannt" — und das sieht aus wie
|
||||
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
|
||||
Windows-Fenster gesehen.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
@@ -328,9 +344,591 @@ def test_snapshot_liefert_alle_vier_bereiche(monkeypatch):
|
||||
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
|
||||
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||
#
|
||||
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
|
||||
assert set(zustand) == {"jobs", "workers", "logs", "devices"}
|
||||
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||
assert "info" in zustand, (
|
||||
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
|
||||
"bis zu 15 Sekunden auf 'unbekannt'."
|
||||
)
|
||||
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
|
||||
assert zustand["workers"] == [{"name": "pc"}]
|
||||
|
||||
|
||||
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
|
||||
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
|
||||
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.db, "list_workers", lambda: [])
|
||||
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
|
||||
async def platzt():
|
||||
raise RuntimeError("Platte weg")
|
||||
|
||||
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
|
||||
|
||||
|
||||
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
||||
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
|
||||
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
|
||||
— auf einem PC ohne Container und ohne zweiten Worker.
|
||||
|
||||
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
|
||||
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
|
||||
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
|
||||
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
|
||||
# Lauf 170 rot geworden. /betrieb braucht keine.
|
||||
antwort = TestClient(app).get("/betrieb")
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["modus"] in ("standalone", "verteilt")
|
||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||
"container_pfade", "werkzeuge_verwalten",
|
||||
"frei_blaettern"}
|
||||
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
|
||||
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
|
||||
# der keinen Container hat (Befund 28.08.2026).
|
||||
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
|
||||
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||
if not daten["im_container"]:
|
||||
assert daten["hilfe_befehl"] == ""
|
||||
|
||||
|
||||
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
|
||||
#
|
||||
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
|
||||
#
|
||||
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||
#
|
||||
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
|
||||
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
|
||||
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
|
||||
# auch `running` auf `processing` ab.
|
||||
#
|
||||
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
|
||||
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
|
||||
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
|
||||
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
|
||||
|
||||
|
||||
def _echte_db_zeile():
|
||||
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"id": "j1",
|
||||
"disc_type": "bluray", # NICHT "type"
|
||||
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
|
||||
"finished_at": None,
|
||||
"status": "running", # wird zu "processing"
|
||||
"device": r"\.\G:",
|
||||
"progress": 12,
|
||||
"title": "Evangelion 2.22",
|
||||
"error": None,
|
||||
"meta": None,
|
||||
}
|
||||
|
||||
|
||||
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
|
||||
import main
|
||||
|
||||
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||
assert ui["type"] == "bluray"
|
||||
assert ui["startTime"].startswith("2026-08-29")
|
||||
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
|
||||
|
||||
|
||||
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
|
||||
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
|
||||
Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft."""
|
||||
import main
|
||||
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
|
||||
|
||||
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||
kurz = _job_kurz(ui)
|
||||
for feld in UI_PFLICHTFELDER:
|
||||
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
|
||||
assert kurz["type"] == "bluray"
|
||||
assert kurz["status"] == "processing"
|
||||
|
||||
|
||||
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
|
||||
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.startup_event)
|
||||
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
|
||||
assert "_job_row_to_model" in quelle
|
||||
|
||||
|
||||
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
|
||||
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
|
||||
|
||||
Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
|
||||
|
||||
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
|
||||
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
|
||||
(beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der
|
||||
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
|
||||
weiter oben stand.
|
||||
|
||||
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main)
|
||||
# Die eine erlaubte Fundstelle ist die Funktion selbst.
|
||||
stellen = quelle.count("device_discovery.device_info(")
|
||||
assert stellen == 1, (
|
||||
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
|
||||
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
|
||||
for name in ("_snapshot", "startup_event", "get_devices"):
|
||||
text = inspect.getsource(getattr(main, name))
|
||||
assert "device_info(" not in text, \
|
||||
"%s baut die Laufwerksliste selbst" % name
|
||||
|
||||
|
||||
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraete = main.laufwerke_mit_disc()
|
||||
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
|
||||
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||
|
||||
assert "disc" not in main.laufwerke_mit_disc()[0]
|
||||
|
||||
|
||||
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
|
||||
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
|
||||
sein."
|
||||
|
||||
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg —
|
||||
damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und
|
||||
einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
|
||||
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
|
||||
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
|
||||
(gemessen: Disc nach 119 s erkannt).
|
||||
"""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||
|
||||
geraet = main.laufwerke_mit_disc()[0]
|
||||
assert geraet["disc_wird_erkannt"] is True
|
||||
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
|
||||
assert "disc" not in geraet
|
||||
|
||||
|
||||
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||
"type": "bluray", "status": "ready"})
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraet = main.laufwerke_mit_disc()[0]
|
||||
assert not geraet.get("disc_wird_erkannt")
|
||||
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
|
||||
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
|
||||
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
|
||||
import main
|
||||
|
||||
assert "disc_wird_erkannt" in main.Device.model_fields
|
||||
|
||||
|
||||
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
|
||||
#
|
||||
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
|
||||
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
|
||||
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
|
||||
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
|
||||
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||
# sein soll.
|
||||
|
||||
|
||||
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||
"type": "bluray", "status": "ready"}])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||
|
||||
geraete = main.laufwerke_notdurft()
|
||||
assert geraete[0]["disc_wird_erkannt"] is True
|
||||
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
|
||||
|
||||
|
||||
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
|
||||
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
|
||||
„behalte deinen Stand", und das ist die ehrliche Antwort."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||
assert main.laufwerke_notdurft() is None
|
||||
|
||||
|
||||
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
|
||||
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
|
||||
nur das Abfragen des Laufwerks ist teuer."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: [r"\.\G:"])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||
|
||||
geraete = main.laufwerke_notdurft()
|
||||
assert geraete[0]["disc_wird_erkannt"] is True
|
||||
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
|
||||
|
||||
|
||||
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
|
||||
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||
"type": "bluray", "status": "ready",
|
||||
"disc_wird_erkannt": True}])
|
||||
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||
|
||||
geraet = main.laufwerke_notdurft()[0]
|
||||
assert "disc_wird_erkannt" not in geraet
|
||||
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||
|
||||
|
||||
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
|
||||
#
|
||||
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
|
||||
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
|
||||
# fenster ganz kurz im hintergrund."
|
||||
#
|
||||
# Beides war dieselbe Schleife: `except OSError: continue` liess
|
||||
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
|
||||
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
|
||||
# naechste Statusabfrage scheitert — und so weiter.
|
||||
|
||||
|
||||
def _CDS():
|
||||
import main
|
||||
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
|
||||
|
||||
|
||||
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(False, None, ok) == "erststart"
|
||||
|
||||
|
||||
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
|
||||
import main
|
||||
_, leer, _ = _CDS()
|
||||
assert main.disc_entscheidung(False, None, leer) == ""
|
||||
|
||||
|
||||
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
|
||||
"""Der Kern des Befundes."""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||
|
||||
|
||||
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
|
||||
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
|
||||
„noch nie gesehen" verwechselt, scannt endlos.
|
||||
|
||||
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
|
||||
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
|
||||
"""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
|
||||
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
|
||||
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
|
||||
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
|
||||
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
|
||||
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||
|
||||
|
||||
def test_auswurf_wird_erkannt():
|
||||
import main
|
||||
ok, leer, offen = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
|
||||
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
|
||||
|
||||
|
||||
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
|
||||
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
|
||||
weg, sobald das Laufwerk kurz beschaeftigt ist."""
|
||||
import main
|
||||
ok, _, _ = _CDS()
|
||||
assert main.disc_entscheidung(True, ok, 3) == ""
|
||||
|
||||
|
||||
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
|
||||
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.disc_watcher)
|
||||
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
|
||||
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
|
||||
|
||||
|
||||
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
|
||||
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
|
||||
|
||||
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||
gemessen' aber messung wird nicht abgeschlossen."
|
||||
|
||||
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
|
||||
seine Jobs mit dem nackten `_job_row_to_model` — also ohne. Seit V2-3
|
||||
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
|
||||
also brav berechnet und niemandem gezeigt.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main._snapshot)
|
||||
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
|
||||
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
|
||||
|
||||
|
||||
def test_die_jobliste_traegt_die_restzeit_felder():
|
||||
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
|
||||
import main
|
||||
|
||||
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
|
||||
assert feld in main.Job.model_fields, feld
|
||||
|
||||
|
||||
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
|
||||
#
|
||||
# Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
|
||||
# bereits bei 30% und er liest immernoch."
|
||||
#
|
||||
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
|
||||
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
|
||||
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
|
||||
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
|
||||
# wissen wir — der Job laeuft ja auf ihr.
|
||||
|
||||
|
||||
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
gescannt = []
|
||||
|
||||
class NieBenutzt:
|
||||
def scan(self, pfad):
|
||||
gescannt.append(pfad)
|
||||
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
|
||||
|
||||
monkeypatch.setattr(main, "PreScan", NieBenutzt)
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||
assert gescannt == []
|
||||
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
|
||||
|
||||
|
||||
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
class Ergebnis:
|
||||
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
|
||||
|
||||
def to_dict(self):
|
||||
return {"title": "Akira"}
|
||||
|
||||
class Scanner:
|
||||
def scan(self, pfad):
|
||||
return Ergebnis()
|
||||
|
||||
monkeypatch.setattr(main, "PreScan", Scanner)
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
|
||||
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
|
||||
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
|
||||
lambda p: asyncio.sleep(0))
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
|
||||
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||
|
||||
|
||||
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
|
||||
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
|
||||
Ende stehen — der Scan davor haelt sie ja fest."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
|
||||
if not quelle:
|
||||
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
|
||||
quelle = inspect.getsource(main)
|
||||
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
|
||||
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
|
||||
|
||||
|
||||
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
|
||||
#
|
||||
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
|
||||
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
|
||||
#
|
||||
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
|
||||
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
|
||||
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
|
||||
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
|
||||
|
||||
|
||||
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
gestartet = []
|
||||
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
|
||||
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
|
||||
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
|
||||
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
|
||||
|
||||
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
|
||||
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
|
||||
|
||||
|
||||
def test_job_offen_zaehlt_die_kompression_mit():
|
||||
from rippy.store import JOB_OFFEN
|
||||
|
||||
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
|
||||
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
|
||||
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
|
||||
|
||||
|
||||
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
||||
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
|
||||
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
|
||||
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
|
||||
import inspect
|
||||
|
||||
from rippy import store
|
||||
|
||||
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
||||
|
||||
|
||||
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
|
||||
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
|
||||
|
||||
12:49:52 bluray-Rip gestartet
|
||||
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||
12:50:12 makemkvcon endete mit Code 11
|
||||
|
||||
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
|
||||
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
|
||||
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
|
||||
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
|
||||
"""
|
||||
import main
|
||||
|
||||
gefragt = []
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: ["/dev/sr0"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||
|
||||
stand = main.laufwerke_mit_disc()
|
||||
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
|
||||
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
|
||||
|
||||
|
||||
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
|
||||
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
|
||||
import main
|
||||
|
||||
gefragt = []
|
||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||
lambda: ["/dev/sr0"])
|
||||
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||
|
||||
main.laufwerke_mit_disc()
|
||||
assert gefragt == ["/dev/sr0"]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen.
|
||||
|
||||
## Warum es diese Tests gibt (Befund 28.08.2026)
|
||||
|
||||
Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine
|
||||
Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar:
|
||||
|
||||
**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und
|
||||
`clients/thetvdb.py` machten im Konstruktor `from db import get_settings`.
|
||||
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
|
||||
mehr — main.py schreibt seitdem `from rippy import store as db`, diese drei
|
||||
Module haben es nie mitbekommen. Damit starb JEDE Metadaten-Abfrage schon
|
||||
beim Erzeugen des Clients mit `ModuleNotFoundError`, auf Windows UND auf der
|
||||
VM. `_auto_prescan` verschluckte es in seinem `except Exception`.
|
||||
|
||||
**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an — den
|
||||
Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein
|
||||
Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan
|
||||
mit.
|
||||
|
||||
Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht.
|
||||
Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen
|
||||
den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_tmdb_client_baut_sich_ohne_datenbank():
|
||||
"""Der Test, der den Phantom-Import gefangen haette."""
|
||||
from clients.tmdb import TMDBClient
|
||||
|
||||
klient = TMDBClient()
|
||||
assert hasattr(klient, "api_key")
|
||||
|
||||
|
||||
def test_omdb_client_baut_sich_ohne_datenbank():
|
||||
from clients.omdb import OMDbClient
|
||||
|
||||
assert OMDbClient() is not None
|
||||
|
||||
|
||||
def test_thetvdb_client_baut_sich_ohne_datenbank():
|
||||
from clients.thetvdb import TheTVDBClient
|
||||
|
||||
assert TheTVDBClient() is not None
|
||||
|
||||
|
||||
def test_kein_client_importiert_das_verschwundene_db_modul():
|
||||
"""Der Waechter gegen genau diesen Fehler, mechanisch.
|
||||
|
||||
Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc
|
||||
einlegt — und dann als „keine Metadaten", nicht als Import-Fehler.
|
||||
"""
|
||||
import os
|
||||
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
fundstellen = []
|
||||
for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")):
|
||||
ordner[:] = [o for o in ordner if o != "__pycache__"]
|
||||
for name in dateien:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
pfad = os.path.join(wurzel, name)
|
||||
with open(pfad, encoding="utf-8") as f:
|
||||
for nr, zeile in enumerate(f, 1):
|
||||
if zeile.strip().startswith(("from db import", "import db")):
|
||||
fundstellen.append("%s:%d" % (name, nr))
|
||||
assert not fundstellen, (
|
||||
"docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt "
|
||||
"rippy.store. Fundstellen: " + ", ".join(fundstellen))
|
||||
|
||||
|
||||
# ── Der Zwischenspeicher ────────────────────────────────────────────────
|
||||
def test_cache_ohne_redis_liefert_None_statt_zu_werfen():
|
||||
"""Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.
|
||||
|
||||
Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den
|
||||
Pre-Scan mit — und die Disc blieb namenlos.
|
||||
"""
|
||||
from cache import cache
|
||||
|
||||
class ToterRedis:
|
||||
def get(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def set(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def setex(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def delete(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def flushdb(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
def ping(self, *a, **k):
|
||||
raise cache.RedisError("nicht erreichbar")
|
||||
|
||||
echt = cache.redis_client
|
||||
cache.redis_client = ToterRedis()
|
||||
try:
|
||||
assert cache.get("egal") is None
|
||||
assert cache.set("egal", {"a": 1}) is False
|
||||
assert cache.delete("egal") is False
|
||||
assert cache.clear() is False
|
||||
cache.init_cache() # darf ebenfalls nicht werfen
|
||||
assert cache.zustand()["erreichbar"] is False
|
||||
finally:
|
||||
cache.redis_client = echt
|
||||
|
||||
|
||||
def test_cache_meldet_den_ausfall_nur_EINMAL(capsys):
|
||||
"""Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf
|
||||
kaeme sie mehrfach."""
|
||||
from cache import cache
|
||||
|
||||
class ToterRedis:
|
||||
def get(self, *a, **k):
|
||||
raise cache.RedisError("weg")
|
||||
|
||||
echt, zustand = cache.redis_client, cache._erreichbar
|
||||
cache.redis_client = ToterRedis()
|
||||
cache._erreichbar = None
|
||||
try:
|
||||
for _ in range(5):
|
||||
cache.get("x")
|
||||
ausgabe = capsys.readouterr().out
|
||||
assert ausgabe.count("nicht erreichbar") == 1, ausgabe
|
||||
finally:
|
||||
cache.redis_client, cache._erreichbar = echt, zustand
|
||||
|
||||
|
||||
def test_ein_kaputter_eintrag_wirft_nicht():
|
||||
"""Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die
|
||||
Antwort eben frisch geholt."""
|
||||
from cache import cache
|
||||
|
||||
class KaputterRedis:
|
||||
def get(self, *a, **k):
|
||||
return "{kein json"
|
||||
|
||||
echt = cache.redis_client
|
||||
cache.redis_client = KaputterRedis()
|
||||
try:
|
||||
assert cache.get("x") is None
|
||||
finally:
|
||||
cache.redis_client = echt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"])
|
||||
def test_jeder_client_ist_importierbar(name):
|
||||
"""Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand
|
||||
eine Disc einlegt."""
|
||||
__import__("clients." + name)
|
||||
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||
assert speicher == {} # nichts geschrieben
|
||||
|
||||
|
||||
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
|
||||
#
|
||||
# Commander 29.08.2026: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||
#
|
||||
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
|
||||
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
|
||||
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
|
||||
# Punkt, und `restzeit_sekunden` braucht zwei.
|
||||
|
||||
|
||||
def _ohne_cache():
|
||||
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
|
||||
return (lambda k: None), (lambda k, v, expire=None: False)
|
||||
|
||||
|
||||
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
|
||||
"""DER Fall des Commanders."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
|
||||
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
|
||||
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
|
||||
|
||||
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
|
||||
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
|
||||
|
||||
|
||||
def test_der_cache_hat_weiterhin_vorrang():
|
||||
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
|
||||
API-Neustart, das Woerterbuch im Prozess nicht."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
|
||||
ergebnis = eta.aktualisiere_und_schaetze(
|
||||
"j2", "running", 25, 620.0,
|
||||
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
|
||||
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
|
||||
assert ergebnis["sekunden"] > 0
|
||||
|
||||
|
||||
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
|
||||
"""Rip und Kompression haben nichts miteinander zu tun."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
|
||||
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
|
||||
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
|
||||
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
|
||||
|
||||
|
||||
def test_alte_reihen_wachsen_nicht_unbegrenzt():
|
||||
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
|
||||
import eta
|
||||
|
||||
eta._REIHEN.clear()
|
||||
lesen, schreiben = _ohne_cache()
|
||||
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
|
||||
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
|
||||
eta.aktualisiere_und_schaetze("neu", "running", 10,
|
||||
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
|
||||
assert eta.schluessel("alt") not in eta._REIHEN
|
||||
assert eta.schluessel("neu") in eta._REIHEN
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||
import pytest
|
||||
|
||||
from makemkv_key import extract_key
|
||||
|
||||
# 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")
|
||||
assert treffer == _KEY
|
||||
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.metadata["type"] == "unknown"
|
||||
assert ergebnis.title == "Nix Da"
|
||||
|
||||
|
||||
# ── Was auf FREMDEN Rechnern gilt (Befund 28.08.2026) ───────────────────
|
||||
#
|
||||
# Der Commander: "du hast jetzt nur mein laufwerk analysiert oder? wenn ich
|
||||
# rippy weitergebe wie laeuft es dort?"
|
||||
#
|
||||
# Berechtigt. Gemessen wurde an EINEM Laufwerk (LG BU40N) und EINER Disc
|
||||
# (BD-50 mit BDMV/META). Diese Tests halten fest, was bei ANDEREN Discs
|
||||
# passieren muss -- ohne dass eine davon vorhanden sein muss.
|
||||
def test_bluray_ohne_metadaten_gibt_ehrlich_nichts_zurueck(tmp_path):
|
||||
"""Sehr viele Blu-rays haben kein BDMV/META. Dann greift der Rueckfall
|
||||
auf das Volume-Label -- aber diese Funktion darf nichts erfinden."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
(tmp_path / "BDMV" / "STREAM").mkdir(parents=True)
|
||||
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_dvd_hat_kein_bdmv(tmp_path):
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
(tmp_path / "VIDEO_TS").mkdir()
|
||||
assert titel_aus_bdmt(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_bdmt_wird_gelesen_wenn_da(tmp_path):
|
||||
"""Der Weg, der bei der Disc des Commanders 'Evangelion 2.22' lieferte —
|
||||
hier ohne Disc nachgestellt."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||
meta.mkdir(parents=True)
|
||||
(meta / "bdmt_deu.xml").write_text(
|
||||
'<?xml version="1.0"?><disclib><di:discinfo>'
|
||||
'<di:title><di:name>Evangelion 2.22</di:name></di:title>'
|
||||
'</di:discinfo></disclib>', encoding="utf-8")
|
||||
assert titel_aus_bdmt(str(tmp_path)) == "Evangelion 2.22"
|
||||
|
||||
|
||||
def test_englisch_wird_bevorzugt(tmp_path):
|
||||
"""Die APIs suchen mit englischen Titeln besser."""
|
||||
from prescan.prescan import titel_aus_bdmt
|
||||
|
||||
meta = tmp_path / "BDMV" / "META" / "DL"
|
||||
meta.mkdir(parents=True)
|
||||
for datei, name in (("bdmt_deu.xml", "Deutscher Titel"),
|
||||
("bdmt_eng.xml", "English Title")):
|
||||
(meta / datei).write_text(
|
||||
"<x><di:name>%s</di:name></x>" % name, encoding="utf-8")
|
||||
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||
|
||||
|
||||
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||
Programme liegen in „Programme", nicht im PATH) und war unter Docker
|
||||
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
|
||||
der Aufruf noch die shutil.which-Suche zurueckkommen.
|
||||
|
||||
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden` —
|
||||
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
|
||||
die Entfernung deshalb erst dort gemeldet.)"""
|
||||
import os
|
||||
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(hier, "prescan", "prescan.py"), encoding="utf-8") as f:
|
||||
# NUR Code, keine Kommentare: Der erste Anlauf dieses Tests fiel ueber
|
||||
# den eigenen Erklaertext, in dem der alte Aufruf zitiert wird. Ein
|
||||
# Waechter, der an einer Erklaerung scheitert, prueft das Falsche.
|
||||
code = [z for z in f if not z.strip().startswith("#")]
|
||||
quelle = "".join(code)
|
||||
assert "shutil.which(" not in quelle, (
|
||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||
assert "makemkvcon" not in quelle, (
|
||||
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
|
||||
|
||||
## Der Befund des Commanders (28.08.2026)
|
||||
|
||||
> „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
|
||||
> cover."
|
||||
|
||||
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
|
||||
sondern eine Bedingung im Pre-Scan:
|
||||
|
||||
if movie.get("title", "").lower() == kandidat.lower():
|
||||
|
||||
An seiner Disc gemessen:
|
||||
|
||||
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||
'Evangelion' 20 Treffer irgendein Evangelion
|
||||
|
||||
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
|
||||
Poster — und wurde verworfen, weil der Titel nicht woertlich gleich war.
|
||||
|
||||
## Warum die Trefferzahl und nicht die Aehnlichkeit
|
||||
|
||||
„Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
|
||||
Aehnlichkeit — das steht als Messung schon laenger im Code und faellt durch
|
||||
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
|
||||
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
|
||||
weiss was er sucht. Wer 20 bekommt, hat geraten.
|
||||
|
||||
Diese Tests spritzen die Clients ein — kein Netz, keine Schluessel, keine
|
||||
Disc.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeTMDB:
|
||||
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
|
||||
|
||||
def __init__(self, treffer_je_suche, details=None):
|
||||
self.treffer = treffer_je_suche
|
||||
self.details = details or {}
|
||||
self.gefragt = []
|
||||
|
||||
def search_movie(self, begriff):
|
||||
self.gefragt.append(begriff)
|
||||
return self.treffer.get(begriff, [])
|
||||
|
||||
def search_tv(self, begriff):
|
||||
return []
|
||||
|
||||
def get_movie_details(self, kennung):
|
||||
return self.details.get(kennung)
|
||||
|
||||
def get_tv_details(self, kennung):
|
||||
return None
|
||||
|
||||
def find_by_imdb(self, kennung):
|
||||
return None
|
||||
|
||||
|
||||
class StummerClient:
|
||||
def lookup(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
DETAILS_2_0 = {
|
||||
22843: {
|
||||
"title": "Evangelion: 2.0 You Can (Not) Advance",
|
||||
"release_date": "2009-06-27",
|
||||
"overview": "Der Pilotin Mari gelingt es …",
|
||||
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
|
||||
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
|
||||
"runtime": 112,
|
||||
"genres": [{"name": "Animation"}],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _prescan(tmdb):
|
||||
from prescan.prescan import PreScan
|
||||
|
||||
p = PreScan()
|
||||
p.tmdb = tmdb
|
||||
p.jikan = StummerClient()
|
||||
p.omdb = StummerClient()
|
||||
return p
|
||||
|
||||
|
||||
def _toc(titel):
|
||||
return {"title": titel, "year": None, "tracks": [], "duration": 0,
|
||||
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
|
||||
|
||||
|
||||
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
|
||||
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
|
||||
tmdb = FakeTMDB(
|
||||
{"Evangelion 2.22": [{"id": 22843,
|
||||
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
|
||||
DETAILS_2_0)
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||
|
||||
assert ergebnis.confidence == 0.8
|
||||
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
|
||||
assert ergebnis.year == 2009
|
||||
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
|
||||
assert ergebnis.metadata["source"] == "tmdb"
|
||||
|
||||
|
||||
def test_woertlich_gleicher_titel_bleibt_sicherer():
|
||||
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
|
||||
tmdb = FakeTMDB(
|
||||
{"Logan": [{"id": 22843, "title": "Logan"}]},
|
||||
{22843: dict(DETAILS_2_0[22843], title="Logan")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
|
||||
assert ergebnis.confidence == 0.95
|
||||
|
||||
|
||||
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
|
||||
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
|
||||
werden. Es bleibt beim ehrlichen Vorschlag."""
|
||||
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
|
||||
tmdb = FakeTMDB({"Evangelion": viele},
|
||||
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
|
||||
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
|
||||
|
||||
|
||||
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
|
||||
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
|
||||
|
||||
Sonst wuerde „Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
|
||||
„Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
|
||||
"""
|
||||
tmdb = FakeTMDB(
|
||||
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
|
||||
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
|
||||
assert ergebnis.confidence == 0.6
|
||||
|
||||
|
||||
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
|
||||
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
|
||||
assert ergebnis.confidence == 0.3
|
||||
assert ergebnis.metadata["type"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
|
||||
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
|
||||
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
|
||||
desselben Films, aber eng genug, um Raten auszuschliessen."""
|
||||
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
|
||||
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
|
||||
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
|
||||
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
|
||||
assert ergebnis.confidence == erwartet
|
||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||
|
||||
|
||||
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||
# prompt sieben Tests rot geworden).
|
||||
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||
|
||||
|
||||
def test_suche_liefert_nur_was_existiert():
|
||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["movies", "rippy"],
|
||||
isdir=lambda p: p in vorhanden,
|
||||
)
|
||||
orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
||||
assert f"/app/media/{JOB}" in orte
|
||||
|
||||
|
||||
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||
#: Gegenstück zu CONTAINER weiter oben.
|
||||
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||
|
||||
|
||||
def test_laufwerks_wurzel_bleibt_absolut():
|
||||
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||
|
||||
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||
beschädigten Datenträger für etwas, das schon dalag.
|
||||
"""
|
||||
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||
assert "F:" + chr(92) + JOB in orte
|
||||
assert "F:" + JOB not in orte
|
||||
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||
|
||||
|
||||
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||
nicht am Trenner scheitern."""
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||
assert f"/app/media/{JOB}" in orte
|
||||
|
||||
|
||||
def test_ohne_job_id_nichts():
|
||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||
|
||||
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
||||
return p == f"/app/temp/raw/{JOB}"
|
||||
|
||||
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}"]
|
||||
|
||||
|
||||
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
||||
raise OSError("kein /app/media")
|
||||
|
||||
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}"]
|
||||
|
||||
|
||||
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
||||
JOB, "",
|
||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||
)
|
||||
orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
|
||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
||||
return "unklar" if "totes-nas" in pfad else "weg"
|
||||
|
||||
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["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"
|
||||
|
||||
+79
-11
@@ -7,6 +7,8 @@ import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||
import { BetriebProvider } from './lib/useBetrieb'
|
||||
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
@@ -50,10 +52,45 @@ function AppInhalt() {
|
||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||
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(() => {
|
||||
api.get('/setup')
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
let abgemeldet = false
|
||||
let versuche = 0
|
||||
|
||||
const fragen = () => {
|
||||
api.get('/setup')
|
||||
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
|
||||
.catch(() => {
|
||||
if (abgemeldet) return
|
||||
versuche += 1
|
||||
// Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis
|
||||
// dahin nicht, hat der Nutzer ein anderes Problem als den
|
||||
// Assistenten — dann zeigen wir die Oberfläche, damit er die Logs
|
||||
// sehen kann.
|
||||
if (versuche < 60) setTimeout(fragen, 2000)
|
||||
else setSetupDone(true)
|
||||
})
|
||||
}
|
||||
fragen()
|
||||
return () => { abgemeldet = true }
|
||||
}, [])
|
||||
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
@@ -63,7 +100,22 @@ function AppInhalt() {
|
||||
{ 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)} />
|
||||
|
||||
return (
|
||||
@@ -110,10 +162,19 @@ function AppInhalt() {
|
||||
|
||||
{/* Main Content Viewport */}
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
{/* 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 === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</Fehlergrenze>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -123,9 +184,16 @@ export default function App() {
|
||||
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
|
||||
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das
|
||||
// alte Problem in neuer Form.
|
||||
// BetriebProvider ganz aussen: Was Rippy anzeigen DARF, entscheidet sich
|
||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||
return (
|
||||
<EventStreamProvider>
|
||||
<AppInhalt />
|
||||
</EventStreamProvider>
|
||||
<Fehlergrenze>
|
||||
<BetriebProvider>
|
||||
<EventStreamProvider>
|
||||
<AppInhalt />
|
||||
</EventStreamProvider>
|
||||
</BetriebProvider>
|
||||
</Fehlergrenze>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ interface Device {
|
||||
serial?: string
|
||||
model?: string
|
||||
disc?: DiscInfo
|
||||
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
|
||||
disc_wird_erkannt?: boolean
|
||||
}
|
||||
|
||||
function posterUrl(disc?: DiscInfo): string | null {
|
||||
@@ -179,6 +181,34 @@ export default function DeviceDiscovery() {
|
||||
</CardHeader>
|
||||
|
||||
<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) */}
|
||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||
<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 { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -74,6 +75,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
const [makemkvKey, setMakemkvKey] = useState('')
|
||||
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||
const betrieb = useBetrieb()
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||||
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."
|
||||
/>
|
||||
<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}
|
||||
gut={encoderWorker.length > 0}
|
||||
gutText={encoderWorker.map(w => {
|
||||
@@ -207,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||
.filter(Boolean).join(', ')
|
||||
return zusatz ? `${w.name} (${zusatz})` : w.name
|
||||
}).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
|
||||
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>
|
||||
</p>
|
||||
<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>
|
||||
{' '}dauern. Deshalb wird eingestellt:
|
||||
</p>
|
||||
|
||||
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
|
||||
<CardTitle>Live-Log</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{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'}
|
||||
</p>
|
||||
</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 { Input, Select } from './ui/Input'
|
||||
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -157,12 +158,20 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
return () => clearInterval(interval)
|
||||
}, [scanStatus, deviceId])
|
||||
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
||||
])
|
||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
|
||||
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||
const betrieb = useBetrieb()
|
||||
|
||||
/*
|
||||
* 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 [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||
const [customPath, setCustomPath] = useState('')
|
||||
@@ -181,6 +190,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
||||
const [arbeitsDir, setArbeitsDir] = 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(() => {
|
||||
if (!isOpen) return
|
||||
@@ -194,6 +207,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setGewaehlt(new Set())
|
||||
setEncoderNode('')
|
||||
setArbeitsDir('')
|
||||
setEigenesArbeitsZiel('')
|
||||
setBrowserOffen(false)
|
||||
setDiscSprachen(null)
|
||||
setAudioWahl(new Set())
|
||||
@@ -213,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||
setStandardAudio(codes(s.audioSprachen))
|
||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||
const basis = s.outputDir || '/app/media'
|
||||
const basis = s.outputDir || betrieb.ablage_vorgabe
|
||||
setTargets([
|
||||
{ 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 },
|
||||
@@ -240,6 +254,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
||||
/*
|
||||
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
|
||||
*
|
||||
* Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er
|
||||
* holt sich das Arbeitsverzeichnis ja von der Installation."
|
||||
*
|
||||
* Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)",
|
||||
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
|
||||
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
|
||||
*/
|
||||
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
|
||||
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
||||
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
||||
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||
@@ -378,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
onChange={e => setArbeitsDir(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Standard aus den Einstellungen
|
||||
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
||||
Standard{arbeitsVorgabe ? ` — ${arbeitsVorgabe}` : ''}
|
||||
</option>
|
||||
{arbeitsZiele.map(z => (
|
||||
<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` : ''}
|
||||
</option>
|
||||
))}
|
||||
{eigenesArbeitsZiel && (
|
||||
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
|
||||
)}
|
||||
</Select>
|
||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -588,12 +615,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
onClick={() => {
|
||||
const auf = !browserOffen
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
|
||||
{browserOffen && (
|
||||
@@ -607,14 +636,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</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
|
||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={!browsePath}
|
||||
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||
>
|
||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
|
||||
</Button>
|
||||
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
|
||||
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
|
||||
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
|
||||
beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar
|
||||
nicht („momentan geht das nicht", 28.08.2026). */}
|
||||
{selectedType !== 'music' && (
|
||||
<Button
|
||||
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={!browsePath}
|
||||
onClick={() => {
|
||||
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
|
||||
setEigenesArbeitsZiel(browsePath)
|
||||
setArbeitsDir(browsePath)
|
||||
}}
|
||||
>
|
||||
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-36 overflow-y-auto p-1">
|
||||
{browseDirs.map(d => (
|
||||
|
||||
@@ -25,9 +25,12 @@ interface 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] || {
|
||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
label: type.toUpperCase(),
|
||||
label: type ? type.toUpperCase() : 'DISC',
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
||||
*
|
||||
* ## Der Befund des Commanders (28.08.2026)
|
||||
*
|
||||
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
||||
*
|
||||
* Worker erreichbar: 0 von 1
|
||||
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
||||
* Container-Platte: unbekannt
|
||||
* Freigaben: keine eingehängt
|
||||
*
|
||||
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
||||
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
||||
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
||||
* installation für Windows gebaut."
|
||||
*
|
||||
* ## Warum ein Provider und kein Abruf je Seite
|
||||
*
|
||||
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
||||
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
||||
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
||||
* Laufzeit nicht; er wird EINMAL geholt.
|
||||
*
|
||||
* ## Warum die Vorgabe „Docker" ist
|
||||
*
|
||||
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
||||
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
|
||||
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
|
||||
* einen Augenblick verschwindet und wieder auftaucht.
|
||||
*
|
||||
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
||||
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
||||
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
export interface BetriebsFaehigkeiten {
|
||||
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
||||
externe_worker: boolean
|
||||
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
||||
freigaben_einhaengen: boolean
|
||||
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
||||
container_pfade: boolean
|
||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||
werkzeuge_verwalten: boolean
|
||||
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||
frei_blaettern: boolean
|
||||
}
|
||||
|
||||
export interface Betrieb {
|
||||
modus: 'standalone' | 'verteilt'
|
||||
plattform: 'windows' | 'linux' | 'macos'
|
||||
im_container: boolean
|
||||
kann: BetriebsFaehigkeiten
|
||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||
ablage_vorgabe: string
|
||||
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
||||
arbeits_vorgabe: string
|
||||
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||
hilfe_befehl: string
|
||||
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
||||
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
||||
geladen: boolean
|
||||
}
|
||||
|
||||
const VORGABE: Betrieb = {
|
||||
modus: 'verteilt',
|
||||
plattform: 'linux',
|
||||
im_container: true,
|
||||
kann: {
|
||||
externe_worker: true,
|
||||
freigaben_einhaengen: true,
|
||||
container_pfade: true,
|
||||
werkzeuge_verwalten: false,
|
||||
frei_blaettern: false,
|
||||
},
|
||||
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
|
||||
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
|
||||
ablage_vorgabe: '',
|
||||
arbeits_vorgabe: '',
|
||||
hilfe_befehl: 'docker compose -p rippy ps',
|
||||
geladen: false,
|
||||
}
|
||||
|
||||
const BetriebContext = createContext<Betrieb>(VORGABE)
|
||||
|
||||
export function BetriebProvider({ children }: { children: ReactNode }) {
|
||||
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
||||
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
api.get('/betrieb')
|
||||
.then(antwort => {
|
||||
const d = antwort.data
|
||||
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
||||
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
||||
// verschwände ein Bereich stillschweigend.
|
||||
if (abgemeldet || !d || !d.kann) return
|
||||
setBetrieb({
|
||||
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
||||
plattform: d.plattform || 'linux',
|
||||
im_container: !!d.im_container,
|
||||
kann: {
|
||||
externe_worker: !!d.kann.externe_worker,
|
||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||
container_pfade: !!d.kann.container_pfade,
|
||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||
frei_blaettern: !!d.kann.frei_blaettern,
|
||||
},
|
||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||
hilfe_befehl: d.hilfe_befehl || '',
|
||||
geladen: true,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
||||
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
||||
})
|
||||
return () => { abgemeldet = true }
|
||||
}, [])
|
||||
|
||||
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
||||
}
|
||||
|
||||
export function useBetrieb(): Betrieb {
|
||||
return useContext(BetriebContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
|
||||
* (Windows-Client oder Docker-All-in-One)?
|
||||
*
|
||||
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
|
||||
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
|
||||
* die nächste falsche Annahme ein.
|
||||
*/
|
||||
export function useAlleinbetrieb(): boolean {
|
||||
return !useBetrieb().kann.externe_worker
|
||||
}
|
||||
@@ -123,6 +123,17 @@ export function EventStreamProvider({ children }: { children: ReactNode }) {
|
||||
devices: d.devices === undefined ? null : d.devices,
|
||||
workers: d.workers ?? [],
|
||||
logs: d.logs ?? [],
|
||||
// ⚠️ Server-Zustand MIT übernehmen (Befund 28.08.2026).
|
||||
//
|
||||
// Er stand hier nicht, obwohl der Schnappschuss ihn liefert — und
|
||||
// `system.status` kommt erst nach bis zu 15 Sekunden. Nach jedem
|
||||
// Neuladen stand deshalb „Platz für Rippy: unbekannt" auf dem
|
||||
// Bildschirm. Das sieht aus wie eine Auskunft und ist keine.
|
||||
//
|
||||
// `undefined` heißt weiterhin „nicht mitgeschickt": dann bleibt der
|
||||
// alte Stand, statt auf leer zu fallen.
|
||||
systemInfo: d.info !== undefined ? d.info : null,
|
||||
ablagen: d.ablagen !== undefined ? d.ablagen : null,
|
||||
verbunden: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
|
||||
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||
return (
|
||||
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
|
||||
|
||||
export default function AnleitungPage() {
|
||||
const fett = "font-semibold text-slate-100"
|
||||
/*
|
||||
* Auch die Anleitung haengt am Betrieb.
|
||||
*
|
||||
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||||
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||||
*
|
||||
* Eine Anleitung, die einem Windows-Nutzer erklaert, wie er Freigaben
|
||||
* einhaengt und weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
|
||||
* das er nicht hat. Das ist schlimmer als gar keine Anleitung: Sie laesst
|
||||
* ihn glauben, er habe etwas falsch gemacht.
|
||||
*/
|
||||
const betrieb = useBetrieb()
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HardDrive} titel="NAS & Speicherziele">
|
||||
<Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
|
||||
{betrieb.kann.freigaben_einhaengen ? (
|
||||
<p>
|
||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Einstellungen → Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
|
||||
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
|
||||
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
|
||||
ein Netzwerkpfad wie <code>\NAS\Filme</code> — Rippy schreibt einfach dorthin.
|
||||
Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
||||
jedem Rip und bricht sonst mit Klartext ab.
|
||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
|
||||
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen →
|
||||
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
|
||||
Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
{/* Nur im verteilten Betrieb. Einem Windows-Nutzer zu erklaeren,
|
||||
wie er weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
|
||||
das er nicht hat — und laesst ihn glauben, er habe etwas falsch
|
||||
gemacht (Commander-Befund 28.08.2026). */}
|
||||
{betrieb.kann.externe_worker && (
|
||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||
<p>
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||
@@ -126,6 +154,7 @@ export default function AnleitungPage() {
|
||||
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
|
||||
</p>
|
||||
</Abschnitt>
|
||||
)}
|
||||
|
||||
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||
<p>
|
||||
|
||||
@@ -11,6 +11,7 @@ import JobDetailModal from '../components/JobDetailModal'
|
||||
import LiveLogSection from '../components/LiveLogSection'
|
||||
import RetryDialog from '../components/RetryDialog'
|
||||
import { useStrom } from '../lib/useEventStream'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
|
||||
interface JobMeta {
|
||||
year?: number
|
||||
@@ -43,7 +44,8 @@ interface Job {
|
||||
|
||||
interface SystemInfo {
|
||||
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[] }[]
|
||||
}
|
||||
|
||||
@@ -64,6 +66,9 @@ interface LaufwerkLive {
|
||||
id: string
|
||||
status: string
|
||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||
/** Die Erkennung laeuft noch — es gibt noch keinen Titel, aber auch kein
|
||||
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
|
||||
disc_wird_erkannt?: boolean
|
||||
}
|
||||
|
||||
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||
@@ -128,6 +133,9 @@ export default function Dashboard() {
|
||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
||||
const strom = useStrom()
|
||||
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
|
||||
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
|
||||
const betrieb = useBetrieb()
|
||||
const { toast } = useToast()
|
||||
|
||||
/*
|
||||
@@ -324,7 +332,13 @@ export default function Dashboard() {
|
||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||
: discImLaufwerk
|
||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||
: 'Bereit — kein Datenträger im Laufwerk'
|
||||
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
|
||||
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
|
||||
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
|
||||
// Fehler (Commander 29.08.2026).
|
||||
: laufwerke.some(l => l.disc_wird_erkannt)
|
||||
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
|
||||
: 'Bereit — kein Datenträger im Laufwerk'
|
||||
|
||||
const filteredJobs = jobs.filter(j => {
|
||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||
@@ -580,6 +594,38 @@ export default function Dashboard() {
|
||||
Details
|
||||
</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' && (
|
||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||
<Download size={13} /> Download
|
||||
@@ -678,7 +724,30 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
|
||||
{/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
|
||||
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
|
||||
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
|
||||
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
|
||||
keinen zweiten Worker, auf den man warten könnte, und
|
||||
kein docker compose, das man prüfen könnte. Die Meldung
|
||||
war nicht nur unpassend, sie war falsch. */}
|
||||
{!betrieb.kann.externe_worker ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
|
||||
</span>
|
||||
<span className="text-slate-200">
|
||||
auf diesem Rechner
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
{aktiverJob
|
||||
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
|
||||
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
@@ -692,7 +761,9 @@ export default function Dashboard() {
|
||||
{workerOnline.length === 0 ? (
|
||||
<p className="text-[11px] font-mono text-rose-400">
|
||||
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||
Prüfen: <span className="text-slate-300">docker compose ps</span>
|
||||
{betrieb.hilfe_befehl && (
|
||||
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
@@ -713,15 +784,52 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
||||
Vorher stand hier nur die Container-Platte; wer wissen
|
||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
{/*
|
||||
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
|
||||
‚Platz für rippy' sollte eher das arbeitsverzeichnis und
|
||||
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
|
||||
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
|
||||
*/}
|
||||
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
|
||||
<div key={ort.name + ort.pfad} className="mb-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="text-amber-400" />
|
||||
{ort.name}:
|
||||
</span>
|
||||
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
|
||||
? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
{ort.pfad && (
|
||||
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
|
||||
{ort.pfad}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!betrieb.kann.container_pfade
|
||||
&& (systemInfo?.plaetze || []).length > 1
|
||||
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
|
||||
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
|
||||
statt die zweite Zeile wegzulassen. */
|
||||
<p className="text-[11px] font-mono text-slate-500 mb-1">
|
||||
Beide auf demselben Laufwerk — der Platz wird geteilt.
|
||||
</p>
|
||||
)}
|
||||
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
|
||||
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
|
||||
<HardDrive size={14} className="text-amber-400" />
|
||||
Container-Platte:
|
||||
</span>
|
||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||
@@ -744,7 +852,16 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
||||
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */}
|
||||
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN.
|
||||
|
||||
⚠️ NUR im Container. Unter Windows hängt Rippy nichts ein:
|
||||
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
|
||||
Der Satz „Ohne Freigabe kann ein externer Encoder nichts
|
||||
tun — er sieht die Container-Platte nicht" stand am
|
||||
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
|
||||
Es gibt weder einen externen Encoder noch eine
|
||||
Container-Platte. */}
|
||||
{betrieb.kann.freigaben_einhaengen && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
@@ -778,6 +895,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { api } from '../lib/api'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||
import { useBetrieb } from '../lib/useBetrieb'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||
@@ -48,7 +50,10 @@ const defaultSettings: SettingsState = {
|
||||
tmdbApiKey: '',
|
||||
tvdbApiKey: '',
|
||||
omdbApiKey: '',
|
||||
outputDir: '/app/media',
|
||||
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
|
||||
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
|
||||
// beim Laden gesetzt.
|
||||
outputDir: '',
|
||||
movieDir: 'movies',
|
||||
seriesDir: 'series',
|
||||
musicDir: 'music',
|
||||
@@ -187,6 +192,7 @@ function zeitLesbar(iso: string): string {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||
const betrieb = useBetrieb()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
@@ -208,6 +214,9 @@ export default function SettingsPage() {
|
||||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||
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 [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||
@@ -248,6 +257,43 @@ export default function SettingsPage() {
|
||||
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) => {
|
||||
setKeydbBusy(true)
|
||||
try {
|
||||
@@ -485,7 +531,12 @@ export default function SettingsPage() {
|
||||
const istFreigabe = (pfad: string) =>
|
||||
ziele.some(z => z.path === pfad && z.is_mount)
|
||||
const ablageWarnung = (() => {
|
||||
const ablage = settings.outputDir || '/app/media'
|
||||
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
|
||||
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
|
||||
// es weder den einen noch die andere — der Satz stand am 28.08.2026
|
||||
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
|
||||
if (!betrieb.kann.externe_worker) return ''
|
||||
const ablage = settings.outputDir || betrieb.ablage_vorgabe
|
||||
const arbeit = settings.workDir
|
||||
if (!arbeit) {
|
||||
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
||||
@@ -505,16 +556,45 @@ export default function SettingsPage() {
|
||||
return ''
|
||||
})()
|
||||
|
||||
/*
|
||||
* Die Reiter haengen am BETRIEB.
|
||||
*
|
||||
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||||
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||||
*
|
||||
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
|
||||
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
|
||||
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
|
||||
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
|
||||
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
|
||||
* Bedienelemente, die ins Leere fuehren.
|
||||
*/
|
||||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||
{ id: 'worker', label: 'Worker', icon: Cpu },
|
||||
...(betrieb.kann.externe_worker
|
||||
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
|
||||
: []),
|
||||
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
|
||||
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
|
||||
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
|
||||
// das steckt weiter unten in einer eigenen Bedingung.
|
||||
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||||
{ id: 'apis', label: 'APIs', icon: Globe },
|
||||
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||||
{ id: 'system', label: 'System', icon: Wrench },
|
||||
]
|
||||
|
||||
/*
|
||||
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
|
||||
*
|
||||
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
|
||||
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
|
||||
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
|
||||
*/
|
||||
const sichtbareReiter = tabs.map(t => t.id)
|
||||
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<PageHeader
|
||||
@@ -533,7 +613,7 @@ export default function SettingsPage() {
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const isActive = aktiverReiter === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -554,7 +634,7 @@ export default function SettingsPage() {
|
||||
{/* Tab Content */}
|
||||
<CardContent className="p-6 space-y-8">
|
||||
{/* Ripping Settings */}
|
||||
{activeTab === 'ripping' && (
|
||||
{aktiverReiter === 'ripping' && (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
@@ -697,7 +777,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Verarbeitung */}
|
||||
{activeTab === 'verarbeitung' && (
|
||||
{aktiverReiter === 'verarbeitung' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Cpu size={20} className="text-amber-500" />
|
||||
@@ -901,12 +981,41 @@ export default function SettingsPage() {
|
||||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||
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
|
||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||
value={settings.workDir}
|
||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||
>
|
||||
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option>
|
||||
<option value="">
|
||||
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||
</option>
|
||||
{ziele.map(z => (
|
||||
<option key={z.path} value={z.path}>
|
||||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||||
@@ -914,13 +1023,35 @@ export default function SettingsPage() {
|
||||
</option>
|
||||
))}
|
||||
</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">
|
||||
<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">
|
||||
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
|
||||
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
|
||||
Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||
{betrieb.kann.container_pfade
|
||||
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
|
||||
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
|
||||
{systemInfo?.plaetze?.length ? (
|
||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||
) : null}
|
||||
@@ -930,7 +1061,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Worker */}
|
||||
{activeTab === 'worker' && (
|
||||
{aktiverReiter === 'worker' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Cpu size={20} className="text-amber-500" />
|
||||
@@ -941,7 +1072,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Speicherziele */}
|
||||
{activeTab === 'speicherziele' && (
|
||||
{aktiverReiter === 'speicherziele' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<HardDrive size={20} className="text-amber-500" />
|
||||
@@ -961,6 +1092,19 @@ export default function SettingsPage() {
|
||||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
||||
</h3>
|
||||
{!betrieb.kann.container_pfade ? (
|
||||
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
|
||||
Windows-PC gibt es keine Liste von Container-Zielen, aus
|
||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||
Bedienung, die nichts bedient. */
|
||||
<OrdnerWaehler
|
||||
label="Ordner"
|
||||
value={settings.outputDir}
|
||||
placeholder={betrieb.ablage_vorgabe}
|
||||
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Ablage"
|
||||
value={settings.outputDir}
|
||||
@@ -974,6 +1118,7 @@ export default function SettingsPage() {
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
||||
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
||||
@@ -1005,12 +1150,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorageMounts />
|
||||
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
|
||||
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
|
||||
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
|
||||
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* APIs */}
|
||||
{activeTab === 'apis' && (
|
||||
{aktiverReiter === 'apis' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Globe size={20} className="text-amber-500" />
|
||||
@@ -1070,7 +1218,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Benachrichtigungen */}
|
||||
{activeTab === 'benachrichtigungen' && (
|
||||
{aktiverReiter === 'benachrichtigungen' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<AlertCircle size={20} className="text-amber-500" />
|
||||
@@ -1120,7 +1268,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* System */}
|
||||
{activeTab === 'system' && (
|
||||
{aktiverReiter === 'system' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Wrench size={20} className="text-amber-500" />
|
||||
@@ -1188,13 +1336,44 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="MakeMKV Beta-Key"
|
||||
value={settings.makemkvAppKey}
|
||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||
/>
|
||||
{/*
|
||||
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
|
||||
type="password"
|
||||
label="MakeMKV Beta-Key"
|
||||
value={settings.makemkvAppKey}
|
||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={keyHolt}
|
||||
onClick={keyJetztHolen}
|
||||
>
|
||||
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||||
</Button>
|
||||
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||||
+ 'der Key wechselt etwa monatlich.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||
|
||||
+161
-35
@@ -47,7 +47,7 @@ import time
|
||||
import requests
|
||||
|
||||
from rippy import store as db
|
||||
from rippy.rip import makemkv_daten
|
||||
from rippy.rip import makemkv_aufruf, makemkv_daten
|
||||
import medien
|
||||
from rippy.core import notify
|
||||
|
||||
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
||||
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.
|
||||
|
||||
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
|
||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||
"""
|
||||
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||
if target_dir:
|
||||
normalisiert = posixpath.normpath(target_dir)
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
normalisiert = _normalisiert(target_dir)
|
||||
if frei or unter_wurzel(normalisiert, wurzel):
|
||||
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
|
||||
`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,
|
||||
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
|
||||
denen niemand gefragt wird.
|
||||
"""
|
||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
||||
# 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.
|
||||
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||
wert = (kandidat or "").strip()
|
||||
if wert:
|
||||
normalisiert = posixpath.normpath(wert)
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return RAW_DIR
|
||||
if not wert:
|
||||
continue
|
||||
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 vorgabe
|
||||
|
||||
|
||||
def _frei_bytes(pfad: str) -> int:
|
||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
||||
kandidat = pfad
|
||||
while kandidat and not os.path.exists(kandidat):
|
||||
kandidat = os.path.dirname(kandidat)
|
||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||||
|
||||
## 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):
|
||||
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:
|
||||
return shutil.disk_usage(kandidat or "/").free
|
||||
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
@@ -243,17 +319,27 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||
if not key:
|
||||
return
|
||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
||||
try:
|
||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
||||
try:
|
||||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
||||
alt = f.read()
|
||||
except OSError:
|
||||
alt = ""
|
||||
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
||||
except OSError as e:
|
||||
# WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
|
||||
# die settings.conf. Begruendung und Messung in
|
||||
# `rippy.rip.makemkv_aufruf.key_ablegen`.
|
||||
#
|
||||
# UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
|
||||
# ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
|
||||
# einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
|
||||
# paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
|
||||
# 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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
return
|
||||
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)
|
||||
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."
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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,
|
||||
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 "".
|
||||
|
||||
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.
|
||||
"""
|
||||
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 (
|
||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||
@@ -1002,7 +1124,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
||||
"status": "error",
|
||||
"error": (
|
||||
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)
|
||||
|
||||
+58
-8
@@ -8,13 +8,39 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||
from rippy.tools import katalog as werkzeuge
|
||||
|
||||
|
||||
def _extern(werte=None, container=None) -> str:
|
||||
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||
|
||||
Drei Fälle, und nur einer davon ist „ja":
|
||||
|
||||
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||
etwas zu übersetzen wäre.
|
||||
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if container is None:
|
||||
container = betrieb.im_container()
|
||||
if container:
|
||||
return "nein"
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||
|
||||
|
||||
def _hb() -> str:
|
||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||
|
||||
@@ -166,7 +192,7 @@ def hole_handbrake_presets() -> list:
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
[_hb(), "--preset-list"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
capture_output=True, timeout=30, **HB_LESEN,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||
@@ -279,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
|
||||
return ""
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER
|
||||
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||
)
|
||||
return (aus.stdout or "") + (aus.stderr or "")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -343,8 +369,18 @@ def werkzeug_versionen() -> dict:
|
||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||
# 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
|
||||
# grundsätzlich nicht komprimieren.
|
||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||
@@ -368,7 +404,7 @@ def werkzeug_versionen() -> dict:
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
[_hb(), "--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
capture_output=True, timeout=15, **HB_LESEN,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
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
|
||||
except Exception:
|
||||
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:
|
||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||
except Exception:
|
||||
|
||||
+349
-17
@@ -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.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||
from rippy.tools import katalog as werkzeuge
|
||||
|
||||
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||
@@ -86,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
||||
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:
|
||||
"""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",
|
||||
"--progress=-same",
|
||||
"mkv",
|
||||
f"dev:{device_path}",
|
||||
makemkv_quelle(device_path),
|
||||
str(titel),
|
||||
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.
|
||||
"""
|
||||
ergebnis = subprocess.run(
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
ausgabe = ergebnis.stdout or ""
|
||||
ausgabe = text_von(ergebnis.stdout)
|
||||
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)."""
|
||||
gesamt = len(titel_liste)
|
||||
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):
|
||||
def anteilig(p, _i=index):
|
||||
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),
|
||||
log_cb=log_cb)
|
||||
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||
if ergebnis.get("status") == "cancelled":
|
||||
return ergebnis
|
||||
if ergebnis.get("status") != "success":
|
||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||
ergebnis["lesefehler"] = lesefehler
|
||||
return ergebnis
|
||||
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):
|
||||
@@ -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)."""
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {}
|
||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
||||
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||
|
||||
|
||||
def parse_scan_dauer(ausgabe: str) -> int:
|
||||
@@ -360,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||
creationflags=OHNE_FENSTER,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
#: 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,
|
||||
preset: str = DEFAULT_HB_PRESET,
|
||||
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.
|
||||
|
||||
`--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.
|
||||
"""
|
||||
befehl = [
|
||||
@@ -508,11 +541,46 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
||||
"--output", output_path,
|
||||
"--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]
|
||||
if audio:
|
||||
befehl += ["--audio-lang-list", ",".join(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]
|
||||
if untertitel:
|
||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||
@@ -577,6 +645,23 @@ def sprachliste(wert) -> list:
|
||||
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,
|
||||
progress_cb=None, abbruch_cb=None,
|
||||
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),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
creationflags=OHNE_FENSTER,
|
||||
**HB_LESEN,
|
||||
)
|
||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||
except Exception as e:
|
||||
@@ -629,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
||||
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:
|
||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||
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:
|
||||
for line in process.stdout:
|
||||
# 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).
|
||||
if 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:
|
||||
falsches_preset = unbekanntes_preset(line)
|
||||
if not falscher_schalter:
|
||||
falscher_schalter = unbekannter_schalter(line)
|
||||
progress = get_progress_from_line(line)
|
||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||
# 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):
|
||||
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:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -671,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
||||
}
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -709,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
||||
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",
|
||||
log_cb=None) -> dict:
|
||||
"""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():
|
||||
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)
|
||||
|
||||
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),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||
# `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,
|
||||
)
|
||||
|
||||
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
|
||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||
# (Befund 24.07., Summer-Wars-UHD).
|
||||
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 = (
|
||||
"volume key is unknown",
|
||||
"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",
|
||||
)
|
||||
try:
|
||||
for line in process.stdout:
|
||||
for rohzeile in process.stdout:
|
||||
line = text_von(rohzeile)
|
||||
progress = get_progress_from_prgv(line)
|
||||
if progress >= 0:
|
||||
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:
|
||||
continue
|
||||
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)
|
||||
if log_cb:
|
||||
try:
|
||||
@@ -786,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
"output_dir": output_dir,
|
||||
"files": mkv_dateien,
|
||||
"return_code": process.returncode,
|
||||
"lesefehler": lesefehler,
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"lesefehler": lesefehler,
|
||||
"error": (
|
||||
f"makemkvcon endete mit Code {process.returncode}"
|
||||
+ (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)
|
||||
|
||||
|
||||
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,
|
||||
auswerfen: bool = True) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||
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():
|
||||
return {
|
||||
"status": "error",
|
||||
|
||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
||||
for pfad in fest:
|
||||
if os.path.isfile(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:
|
||||
|
||||
@@ -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)) == [
|
||||
"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(
|
||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||
"/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(
|
||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||
"/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 "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).
|
||||
|
||||
|
||||
# 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():
|
||||
"""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
|
||||
niemand gefragt wird.
|
||||
"""
|
||||
import ablauf as tasks
|
||||
|
||||
einst = {"workDir": "/app/media/movies"}
|
||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
||||
w = CONTAINER
|
||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||
# 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():
|
||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
|
||||
|
||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||
Windows-Reparatur nichts."""
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
||||
w = CONTAINER
|
||||
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
||||
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||
import ablauf as tasks
|
||||
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
||||
w = CONTAINER
|
||||
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,
|
||||
get_progress_from_line,
|
||||
get_progress_from_prgv,
|
||||
hb_schluss,
|
||||
parse_msg,
|
||||
unbekannter_schalter,
|
||||
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 "--preset" in cmd
|
||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||
assert "--audio-codec" in cmd
|
||||
assert "--aencoder" 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():
|
||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
class FakeProcess:
|
||||
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
||||
return 1
|
||||
|
||||
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) -----------
|
||||
@@ -519,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
|
||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||
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,113 @@
|
||||
# Rippy v5 bauen — Anleitung
|
||||
|
||||
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||
> die Messungen dazu liegen in `beweise/`.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Werkzeug | Version | Anmerkung |
|
||||
|----------|---------|-----------|
|
||||
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||
| npm | 11 | kommt mit Node 24 |
|
||||
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||
|
||||
## Einrichten — zwei Schritte, nicht einer
|
||||
|
||||
```
|
||||
npm install
|
||||
node node_modules/electron/install.js
|
||||
```
|
||||
|
||||
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||
|
||||
## Die Läufe
|
||||
|
||||
| Befehl | Was er tut |
|
||||
|--------|------------|
|
||||
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||
|
||||
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||
fasst die echten Einstellungen nicht an.
|
||||
|
||||
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||
|
||||
## Das Setup bauen (W-6)
|
||||
|
||||
```
|
||||
npm run paket
|
||||
```
|
||||
|
||||
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
|
||||
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
||||
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||
|
||||
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||
(`npx electron-builder --win --config electron-builder.yml
|
||||
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||
|
||||
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||
liegen nicht im Git, 74 MB):
|
||||
|
||||
```
|
||||
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
|
||||
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
|
||||
Aufruf ohne eine Zeile Ausgabe)
|
||||
```
|
||||
|
||||
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
|
||||
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
|
||||
in `bau/lizenzen/QUELLEN.md`.
|
||||
|
||||
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
|
||||
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
|
||||
ERSETZEN) — `electron-updater` prüft
|
||||
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
|
||||
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
|
||||
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
|
||||
|
||||
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
|
||||
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
|
||||
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
|
||||
erkennen.
|
||||
|
||||
## Festgelegte Versionen und warum
|
||||
|
||||
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||
Release prüfen, ob eine neue Linie ansteht.
|
||||
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,29 @@
|
||||
Copyright (C) 2000-2009 Josh Coalson
|
||||
Copyright (C) 2011-2025 Xiph.Org Foundation
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
- Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
- Neither the name of the Xiph.Org Foundation nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,338 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
|
||||
|
||||
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
|
||||
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
|
||||
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
|
||||
holt es beim Einrichten vom Hersteller.
|
||||
|
||||
## HandBrakeCLI
|
||||
|
||||
- Lizenz: GNU General Public License v2 — Volltext in
|
||||
`HANDBRAKE-GPL-2.0.txt` (bezogen von
|
||||
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
|
||||
- Quelltext und Projekt: https://handbrake.fr bzw.
|
||||
https://github.com/HandBrake/HandBrake — der Quelltext der
|
||||
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
|
||||
|
||||
## FLAC (flac.exe + libFLAC.dll)
|
||||
|
||||
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
|
||||
`FLAC-COPYING.Xiph` (bezogen von
|
||||
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
|
||||
30.08.2026).
|
||||
- Projekt: https://xiph.org/flac/
|
||||
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
|
||||
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
|
||||
|
||||
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
|
||||
|
||||
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
|
||||
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
|
||||
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
|
||||
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
|
||||
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
|
||||
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
|
||||
keine Schriften aus dem Netz.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,42 @@
|
||||
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
|
||||
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
|
||||
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
|
||||
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
|
||||
appId: de.krbrz.rippy
|
||||
productName: Rippy
|
||||
directories:
|
||||
output: dist-setup
|
||||
buildResources: bau
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
extraResources:
|
||||
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
|
||||
- from: vendor
|
||||
to: vendor
|
||||
- from: bau/lizenzen
|
||||
to: lizenzen
|
||||
- from: bau/tray.png
|
||||
to: bau/tray.png
|
||||
win:
|
||||
icon: bau/icon.ico
|
||||
target:
|
||||
- nsis
|
||||
nsis:
|
||||
oneClick: true
|
||||
perMachine: false
|
||||
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
|
||||
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
|
||||
allowToChangeInstallationDirectory: false
|
||||
deleteAppDataOnUninstall: false
|
||||
artifactName: RippySetup-${version}.${ext}
|
||||
publish:
|
||||
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
|
||||
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
|
||||
# ersten echten Release gemessen — § 3.6 ⚠).
|
||||
provider: generic
|
||||
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
|
||||
electronDownload:
|
||||
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
|
||||
# lädt die Binärdateien selbst über diesen Cache-Weg.
|
||||
cache: .electron-cache
|
||||
@@ -0,0 +1,67 @@
|
||||
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||
// haupt → out/haupt/index.js (Electron Main)
|
||||
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||
// fenster → out/fenster/ (React)
|
||||
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||
function cspNurImBau(): Plugin {
|
||||
return {
|
||||
name: 'csp-nur-im-bau',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<head>',
|
||||
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||
// bleibt alles bei 'self'.
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/haupt',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/bruecke',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: 'src/fenster',
|
||||
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||
build: {
|
||||
outDir: 'out/fenster',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Generated
+7289
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.1.0",
|
||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||
"main": "out/haupt/index.js",
|
||||
"author": "KrBrZ",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"test": "vitest run",
|
||||
"smoke": "electron-vite build && electron . --smoke",
|
||||
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
"koffi": "^3.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/instrument-sans": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "44.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
|
||||
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
|
||||
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
|
||||
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
|
||||
// Kontextgrenze — contextBridge kann keine Ports übertragen)
|
||||
// 2. den Haupt-Status (Kanal 'haupt-status')
|
||||
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
|
||||
//
|
||||
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
|
||||
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
|
||||
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
|
||||
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
|
||||
let seiteBereit = false
|
||||
const wartendePorts: IpcRendererEvent[] = []
|
||||
|
||||
ipcRenderer.on('kern-port', (ereignis) => {
|
||||
if (seiteBereit) {
|
||||
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||
} else {
|
||||
wartendePorts.push(ereignis)
|
||||
}
|
||||
})
|
||||
|
||||
let letzterStatus: unknown = null
|
||||
const statusHorcher = new Set<(status: unknown) => void>()
|
||||
|
||||
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
|
||||
letzterStatus = status
|
||||
for (const horcher of statusHorcher) horcher(status)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('rippy', {
|
||||
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
|
||||
bereit(): void {
|
||||
seiteBereit = true
|
||||
for (const ereignis of wartendePorts.splice(0)) {
|
||||
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
|
||||
}
|
||||
},
|
||||
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
|
||||
aufStatus(horcher: (status: unknown) => void): void {
|
||||
statusHorcher.add(horcher)
|
||||
if (letzterStatus !== null) horcher(letzterStatus)
|
||||
},
|
||||
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
|
||||
melden(art: string): void {
|
||||
ipcRenderer.send('fenster-meldung', String(art))
|
||||
},
|
||||
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
|
||||
ordnerWaehlen(titel: string): Promise<string | null> {
|
||||
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
|
||||
},
|
||||
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
|
||||
autostartSetzen(an: boolean): Promise<boolean> {
|
||||
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rippy: {
|
||||
bereit(): void
|
||||
aufStatus(horcher: (status: unknown) => void): void
|
||||
melden(art: string): void
|
||||
ordnerWaehlen(titel: string): Promise<string | null>
|
||||
autostartSetzen(an: boolean): Promise<boolean>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
|
||||
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
|
||||
import '@fontsource-variable/bricolage-grotesque'
|
||||
import '@fontsource/instrument-sans/400.css'
|
||||
import '@fontsource/instrument-sans/500.css'
|
||||
import '@fontsource/instrument-sans/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import '@fontsource/jetbrains-mono/700.css'
|
||||
import './stil.css'
|
||||
|
||||
createRoot(document.getElementById('wurzel')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rippy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wurzel"></div>
|
||||
<script type="module" src="./haupt.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,300 @@
|
||||
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
|
||||
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
|
||||
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
|
||||
//
|
||||
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type BeschaffungStand,
|
||||
type BibliothekEintrag,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||
* Vorgang beginnt. */
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
version: string
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 20
|
||||
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) {
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||
}
|
||||
|
||||
function kernNachricht(nachricht: KernNachricht): void {
|
||||
switch (nachricht.art) {
|
||||
case 'kern-bereit':
|
||||
stand.kern = nachricht
|
||||
break
|
||||
case 'pong':
|
||||
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
|
||||
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
|
||||
window.rippy.melden('kern-pong')
|
||||
break
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'auswurf-ergebnis': {
|
||||
const text =
|
||||
nachricht.grund === 'ok'
|
||||
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'beschaffung-status':
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||
// wiederholen den Text — nur echte Änderungen zählen).
|
||||
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||
stand.ripProtokoll = {
|
||||
...stand.ripProtokoll,
|
||||
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||
}
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||
)
|
||||
} else if (nachricht.status.phase === 'fehler') {
|
||||
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
melden()
|
||||
}
|
||||
|
||||
function portAnnehmen(neuerPort: MessagePort): void {
|
||||
port = neuerPort
|
||||
stand.verbunden = true
|
||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
|
||||
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
|
||||
export function verbindungStarten(): void {
|
||||
if (gestartet) return
|
||||
gestartet = true
|
||||
window.addEventListener('message', (ereignis: MessageEvent) => {
|
||||
const daten: unknown = ereignis.data
|
||||
if (
|
||||
typeof daten === 'object' &&
|
||||
daten !== null &&
|
||||
(daten as Record<string, unknown>).art === 'kern-port' &&
|
||||
ereignis.ports.length > 0
|
||||
) {
|
||||
portAnnehmen(ereignis.ports[0])
|
||||
}
|
||||
})
|
||||
window.rippy.bereit()
|
||||
}
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage(nachricht)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
export function ripStarten(id: string): void {
|
||||
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
if (port === null) {
|
||||
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'makemkv-update' })
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
|
||||
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
|
||||
Geräte-Präzision — Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
|
||||
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
|
||||
@theme {
|
||||
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
|
||||
--font-sans: 'Instrument Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', Consolas, monospace;
|
||||
|
||||
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
|
||||
--color-buehne: oklch(0.13 0.012 60);
|
||||
--color-karte: oklch(0.17 0.015 60);
|
||||
--color-samt: oklch(0.16 0.015 60);
|
||||
|
||||
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
|
||||
--color-gold-hell: oklch(0.88 0.09 80);
|
||||
--color-gold: oklch(0.83 0.11 75);
|
||||
--color-gold-tief: oklch(0.74 0.13 60);
|
||||
|
||||
/* Die Aufnahme-LED aus dem Schaltpult. */
|
||||
--color-rec: oklch(0.72 0.17 45);
|
||||
|
||||
/* Schriftstufen — warmes Weiß bis Nebel. */
|
||||
--color-schnee: oklch(0.93 0.01 80);
|
||||
--color-dunst: oklch(0.68 0.015 80);
|
||||
--color-nebel: oklch(0.55 0.015 80);
|
||||
|
||||
/* Zustandsfarben, alle in der warmen Familie. */
|
||||
--color-gruen: oklch(0.78 0.13 150);
|
||||
--color-warn: oklch(0.8 0.12 85);
|
||||
--color-rot: oklch(0.68 0.19 25);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer — liegt als
|
||||
festes Bühnenbild hinter allem. */
|
||||
.buehnenlicht {
|
||||
background:
|
||||
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
|
||||
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
|
||||
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
|
||||
}
|
||||
|
||||
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
|
||||
.filmkorn {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
|
||||
.segment-gluehen {
|
||||
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
|
||||
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
|
||||
}
|
||||
|
||||
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -0,0 +1,306 @@
|
||||
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
||||
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
||||
// Datei läuft im Fenster UND im Kern.
|
||||
//
|
||||
// Wege:
|
||||
// Kern → Haupt über process.parentPort (KernNachricht)
|
||||
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
||||
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
||||
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
||||
|
||||
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||||
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||||
|
||||
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||||
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||||
|
||||
export interface GeraeteInfo {
|
||||
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||||
id: string
|
||||
name: string
|
||||
typ: DiscTyp
|
||||
pfad: string
|
||||
status: LaufwerkStatus
|
||||
modell: string
|
||||
seriennummer: string
|
||||
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||||
groesseBytes: number
|
||||
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export type WacheEreignis =
|
||||
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||||
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||||
| { art: 'laufwerk-weg'; id: string }
|
||||
|
||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||
|
||||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||
export type RipPhase =
|
||||
| 'liest-info'
|
||||
| 'rippt'
|
||||
| 'komprimiert'
|
||||
| 'legt-ab'
|
||||
| 'fertig'
|
||||
| 'fehler'
|
||||
| 'abgebrochen'
|
||||
|
||||
export interface RipStatus {
|
||||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||
id: string
|
||||
phase: RipPhase
|
||||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||||
prozent: number
|
||||
text: string
|
||||
dateien: string[]
|
||||
lesefehler: boolean
|
||||
}
|
||||
|
||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||
export interface DiscInfoStand {
|
||||
id: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||||
confidence: number
|
||||
quelle: string
|
||||
posterPfad: string
|
||||
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||||
backdropPfad: string
|
||||
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||||
beschreibung: string
|
||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||
schonGerippt: string | null
|
||||
}
|
||||
|
||||
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||
export interface BibliothekEintrag {
|
||||
fingerprint: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
typ: string
|
||||
groesseBytes: number
|
||||
ablageort: string
|
||||
datum: string
|
||||
dauerS: number
|
||||
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||||
export interface MetaVorschlag {
|
||||
typ: 'movie' | 'tv'
|
||||
tmdbId: number
|
||||
titel: string
|
||||
jahr: number | null
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
||||
export interface WerkzeugAuskunft {
|
||||
makemkvcon: string
|
||||
handbrake: string
|
||||
flac: string
|
||||
presets: string[]
|
||||
backends: string[]
|
||||
}
|
||||
|
||||
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||||
export interface BeschaffungStand {
|
||||
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||||
werkzeug: string
|
||||
laeuft: boolean
|
||||
text: string
|
||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||
anteil: number | null
|
||||
fehler: boolean
|
||||
}
|
||||
|
||||
/** Nachrichten, die der Kern verschickt. */
|
||||
export type KernNachricht =
|
||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||
| { art: 'pong'; zeit: number }
|
||||
| { art: 'kern-fehler'; text: string }
|
||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
| { art: 'disc-info'; info: DiscInfoStand }
|
||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||||
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||||
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||||
| { art: 'installer-starten'; pfad: string }
|
||||
| {
|
||||
art: 'schluessel-status'
|
||||
stand: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
| { art: 'rip-start'; id: string }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||
| { art: 'einstellungen-laden' }
|
||||
| { art: 'werkzeuge-laden' }
|
||||
| { art: 'bibliothek-laden' }
|
||||
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||||
| { art: 'makemkv-update' }
|
||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||
| { art: 'schluessel-pruefen' }
|
||||
|
||||
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||||
export type HauptNachricht =
|
||||
| { art: 'fenster-port' }
|
||||
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||||
| { art: 'installer-ergebnis'; fehler: string | null }
|
||||
|
||||
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||||
export interface HauptStatus {
|
||||
version: string
|
||||
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
||||
leine: { ok: boolean; fehler?: string }
|
||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||
autostart: boolean
|
||||
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
||||
update: { letzteErfolgreichePruefung: string; text: string }
|
||||
}
|
||||
|
||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||
return typeof wert === 'object' && wert !== null
|
||||
}
|
||||
|
||||
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
switch (wert.art) {
|
||||
case 'kern-bereit':
|
||||
return (
|
||||
typeof wert.pid === 'number' &&
|
||||
typeof wert.nodeVersion === 'string' &&
|
||||
istDbStatus(wert.datenbank)
|
||||
)
|
||||
case 'pong':
|
||||
return typeof wert.zeit === 'number'
|
||||
case 'kern-fehler':
|
||||
return typeof wert.text === 'string'
|
||||
case 'laufwerke':
|
||||
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||||
case 'auswurf-ergebnis':
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.grund === 'string' &&
|
||||
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||
)
|
||||
case 'rip-status':
|
||||
return istRipStatus(wert.status)
|
||||
case 'disc-info':
|
||||
return (
|
||||
istObjekt(wert.info) &&
|
||||
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||
)
|
||||
case 'einstellungen':
|
||||
return istObjekt(wert.werte)
|
||||
case 'werkzeuge':
|
||||
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||||
case 'bibliothek':
|
||||
return Array.isArray(wert.eintraege)
|
||||
case 'schluessel-status':
|
||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||
case 'beschaffung-status':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||||
)
|
||||
case 'installer-starten':
|
||||
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||||
case 'metadaten-vorschlaege':
|
||||
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
||||
if (!istObjekt(wert)) return false
|
||||
return (
|
||||
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
||||
typeof wert.tmdbId === 'number' &&
|
||||
typeof wert.titel === 'string' &&
|
||||
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
||||
typeof wert.posterPfad === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
return (
|
||||
typeof wert.id === 'string' &&
|
||||
typeof wert.phase === 'string' &&
|
||||
typeof wert.prozent === 'number' &&
|
||||
typeof wert.text === 'string' &&
|
||||
Array.isArray(wert.dateien) &&
|
||||
typeof wert.lesefehler === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||||
if (wert.ok === false) return typeof wert.fehler === 'string'
|
||||
return false
|
||||
}
|
||||
|
||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0
|
||||
}
|
||||
if (wert.art === 'einstellung-setzen') {
|
||||
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
||||
}
|
||||
if (wert.art === 'metadaten-suchen') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
||||
}
|
||||
if (wert.art === 'zuordnung-setzen') {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||||
}
|
||||
return (
|
||||
wert.art === 'einstellungen-laden' ||
|
||||
wert.art === 'werkzeuge-laden' ||
|
||||
wert.art === 'bibliothek-laden' ||
|
||||
wert.art === 'makemkv-update' ||
|
||||
wert.art === 'schluessel-pruefen'
|
||||
)
|
||||
}
|
||||
|
||||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'fenster-port') return true
|
||||
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
|
||||
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
|
||||
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
|
||||
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
|
||||
// (VCN) denselben Film in 2 statt 60+ Minuten.
|
||||
//
|
||||
// Regeln:
|
||||
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
|
||||
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
|
||||
// Name (die „Invalid preset"-Falle).
|
||||
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
|
||||
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
|
||||
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
|
||||
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
|
||||
// DVDs richtig; hochskaliert wird nichts).
|
||||
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
|
||||
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
|
||||
|
||||
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
||||
|
||||
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
||||
['vce', 'VCN'],
|
||||
['nvenc', 'NVENC'],
|
||||
['qsv', 'QSV'],
|
||||
]
|
||||
|
||||
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
||||
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
|
||||
export function presetEmpfehlung(
|
||||
discTyp: string,
|
||||
backends: readonly string[],
|
||||
presets: readonly string[],
|
||||
): string {
|
||||
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
|
||||
for (const [backend, kennung] of FAMILIEN) {
|
||||
if (!backends.includes(backend)) continue
|
||||
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
||||
if (treffer !== undefined) return treffer
|
||||
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
|
||||
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
||||
if (kleiner !== undefined) return kleiner
|
||||
}
|
||||
return DEFAULT_HB_PRESET
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
|
||||
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
|
||||
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
|
||||
// eine Administrator-Abfrage und könnte kein Tray zeigen.
|
||||
import { app } from 'electron'
|
||||
|
||||
export function autostartSetzen(an: boolean): void {
|
||||
app.setLoginItemSettings({ openAtLogin: an })
|
||||
}
|
||||
|
||||
export function autostartAktiv(): boolean {
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
|
||||
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
|
||||
// (src/bruecke) und den MessagePort zum Kern.
|
||||
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function fensterErstellen(): BrowserWindow {
|
||||
const fenster = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
||||
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
|
||||
backgroundColor: '#171412',
|
||||
title: 'Rippy',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../bruecke/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
|
||||
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||
if (devUrl !== undefined && devUrl.length > 0) {
|
||||
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
|
||||
} else {
|
||||
void fenster.loadFile(
|
||||
join(__dirname, '../fenster/index.html'),
|
||||
startBereich.length > 0 ? { hash: startBereich } : undefined,
|
||||
)
|
||||
}
|
||||
return fenster
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
|
||||
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
|
||||
// öffnen → Fenster und Kern über einen MessagePort verdrahten. Seit W-5
|
||||
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
|
||||
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
|
||||
//
|
||||
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
|
||||
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
|
||||
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
|
||||
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
|
||||
import { app, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join, win32 } from 'node:path'
|
||||
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
|
||||
import { autostartAktiv, autostartSetzen } from './autostart'
|
||||
import { fensterErstellen } from './fenster'
|
||||
import { KernVerwaltung } from './kernstart'
|
||||
import { leineSetzen, type LeineStatus } from './leine'
|
||||
import { trayErstellen, trayHinweis } from './tray'
|
||||
import { updateEinrichten, updateStand } from './update'
|
||||
|
||||
const smokeModus = process.argv.includes('--smoke')
|
||||
|
||||
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||
if (smokeModus) {
|
||||
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
||||
}
|
||||
|
||||
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
||||
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.exit(0)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
|
||||
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
|
||||
const leine: LeineStatus = leineSetzen()
|
||||
if (!leine.ok) {
|
||||
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
|
||||
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
|
||||
}
|
||||
|
||||
let fenster: BrowserWindow | null = null
|
||||
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
|
||||
let beendenGewollt = smokeModus
|
||||
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
|
||||
// warten dann.
|
||||
const aktiveVorgaenge = new Set<string>()
|
||||
let updateGestartet = false
|
||||
|
||||
const smoke = {
|
||||
fenster: false,
|
||||
kern: false,
|
||||
datenbank: false,
|
||||
leine: leine.ok,
|
||||
pong: false,
|
||||
}
|
||||
|
||||
const kernVerwaltung = new KernVerwaltung(
|
||||
join(app.getPath('userData'), 'rippy.db'),
|
||||
(nachricht) => {
|
||||
if (nachricht.art === 'kern-bereit') {
|
||||
kernBereit = nachricht
|
||||
smoke.kern = true
|
||||
smoke.datenbank = nachricht.datenbank.ok
|
||||
if (!nachricht.datenbank.ok) {
|
||||
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
|
||||
}
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
} else if (nachricht.art === 'rip-status') {
|
||||
ripStatusVerarbeiten(nachricht.status)
|
||||
} else if (nachricht.art === 'einstellungen') {
|
||||
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
||||
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
||||
if (!updateGestartet && !smokeModus) {
|
||||
updateGestartet = true
|
||||
updateEinrichten(
|
||||
nachricht.werte['updateUrl'] ?? '',
|
||||
() => aktiveVorgaenge.size > 0,
|
||||
(text) => toast('Rippy-Update', text),
|
||||
)
|
||||
}
|
||||
} else if (nachricht.art === 'installer-starten') {
|
||||
void installerStarten(nachricht.pfad)
|
||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
|
||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||
toast(
|
||||
'Disc erkannt',
|
||||
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
||||
(nachricht.info.schonGerippt !== null
|
||||
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
|
||||
: ''),
|
||||
)
|
||||
}
|
||||
},
|
||||
() => statusSenden(),
|
||||
smokeModus,
|
||||
)
|
||||
|
||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
||||
aktiveVorgaenge.add(status.id)
|
||||
} else {
|
||||
aktiveVorgaenge.delete(status.id)
|
||||
}
|
||||
if (fenster !== null && !fenster.isDestroyed()) {
|
||||
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
|
||||
fenster.setProgressBar(status.prozent / 100)
|
||||
} else if (status.phase === 'fehler') {
|
||||
fenster.setProgressBar(1, { mode: 'error' })
|
||||
} else {
|
||||
fenster.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
if (status.phase === 'rippt') {
|
||||
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||
} else if (status.phase === 'komprimiert') {
|
||||
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
|
||||
} else {
|
||||
trayHinweis('')
|
||||
}
|
||||
if (smokeModus) return
|
||||
if (status.phase === 'fertig') {
|
||||
toast('Rippy ist fertig', status.text)
|
||||
} else if (status.phase === 'fehler') {
|
||||
toast('Rippy: Fehler', status.text)
|
||||
}
|
||||
}
|
||||
|
||||
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
|
||||
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
|
||||
// Manifest Administratorrechte an — CreateProcess bricht dann mit
|
||||
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
|
||||
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
|
||||
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
|
||||
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
|
||||
async function installerStarten(pfad: string): Promise<void> {
|
||||
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
|
||||
// Rippys eigenem Downloads-Ordner.
|
||||
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
|
||||
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
|
||||
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
|
||||
return
|
||||
}
|
||||
const fehler = await shell.openPath(pfad)
|
||||
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
|
||||
}
|
||||
|
||||
function toast(titel: string, text: string): void {
|
||||
try {
|
||||
if (!Notification.isSupported()) return
|
||||
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
|
||||
meldung.on('click', () => fensterZeigen())
|
||||
meldung.show()
|
||||
} catch (fehler) {
|
||||
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function statusSenden(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) return
|
||||
const status: HauptStatus = {
|
||||
version: app.getVersion(),
|
||||
leine,
|
||||
kern: kernVerwaltung.zustand(),
|
||||
autostart: autostartAktiv(),
|
||||
update: updateStand(),
|
||||
}
|
||||
fenster.webContents.send('haupt-status', status)
|
||||
}
|
||||
|
||||
function fensterVerdrahten(): void {
|
||||
if (fenster === null) return
|
||||
fenster.webContents.on('did-finish-load', () => {
|
||||
smoke.fenster = true
|
||||
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
|
||||
kernVerwaltung.fensterVerbinden(fenster!.webContents)
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
})
|
||||
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
||||
fenster.on('close', (ereignis) => {
|
||||
if (beendenGewollt) return
|
||||
ereignis.preventDefault()
|
||||
fenster?.hide()
|
||||
})
|
||||
}
|
||||
|
||||
function fensterZeigen(): void {
|
||||
if (fenster === null || fenster.isDestroyed()) {
|
||||
fenster = fensterErstellen()
|
||||
fensterVerdrahten()
|
||||
return
|
||||
}
|
||||
if (fenster.isMinimized()) fenster.restore()
|
||||
fenster.show()
|
||||
fenster.focus()
|
||||
}
|
||||
|
||||
function beenden(): void {
|
||||
beendenGewollt = true
|
||||
app.quit()
|
||||
}
|
||||
|
||||
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
||||
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
||||
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
||||
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
|
||||
if (art === 'kern-pong') {
|
||||
smoke.pong = true
|
||||
smokePruefen()
|
||||
}
|
||||
})
|
||||
|
||||
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
||||
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
||||
autostartSetzen(an === true)
|
||||
statusSenden()
|
||||
return autostartAktiv()
|
||||
})
|
||||
|
||||
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
|
||||
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
|
||||
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
|
||||
if (fenster === null) return null
|
||||
const ergebnis = await dialog.showOpenDialog(fenster, {
|
||||
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
fensterZeigen()
|
||||
})
|
||||
|
||||
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
|
||||
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
|
||||
app.on('window-all-closed', () => {
|
||||
if (beendenGewollt) app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
beendenGewollt = true
|
||||
kernVerwaltung.beenden()
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
kernVerwaltung.starten()
|
||||
if (!smokeModus) {
|
||||
trayErstellen(fensterZeigen, beenden)
|
||||
}
|
||||
fenster = fensterErstellen()
|
||||
fensterVerdrahten()
|
||||
})
|
||||
|
||||
let smokeAbgeschlossen = false
|
||||
|
||||
function smokePruefen(): void {
|
||||
if (!smokeModus || smokeAbgeschlossen) return
|
||||
const fehlt = Object.entries(smoke)
|
||||
.filter(([, wert]) => !wert)
|
||||
.map(([name]) => name)
|
||||
if (fehlt.length > 0) return
|
||||
smokeAbgeschlossen = true
|
||||
void smokeAbschliessen()
|
||||
}
|
||||
|
||||
async function smokeAbschliessen(): Promise<void> {
|
||||
const bildPfad = process.env['RIPPY_SMOKE_BILD']
|
||||
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
|
||||
try {
|
||||
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
|
||||
// ein sofortiges capturePage lieferte hier »UnknownVizError«
|
||||
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist;
|
||||
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
|
||||
// späte Kern-Antworten warten.
|
||||
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
|
||||
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 750))
|
||||
const bild = await fenster.webContents.capturePage()
|
||||
await writeFile(bildPfad, bild.toPNG())
|
||||
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
|
||||
} catch (fehler) {
|
||||
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
const kernInfo =
|
||||
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
|
||||
console.log(
|
||||
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
|
||||
)
|
||||
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
|
||||
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
|
||||
kernVerwaltung.beenden()
|
||||
setTimeout(() => app.exit(0), 100)
|
||||
}
|
||||
|
||||
if (smokeModus) {
|
||||
setTimeout(() => {
|
||||
if (smokeAbgeschlossen) return
|
||||
smokeAbgeschlossen = true
|
||||
const fehlt = Object.entries(smoke)
|
||||
.filter(([, wert]) => !wert)
|
||||
.map(([name]) => name)
|
||||
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
|
||||
kernVerwaltung.beenden()
|
||||
setTimeout(() => app.exit(2), 100)
|
||||
}, 25_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
|
||||
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
|
||||
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
|
||||
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
|
||||
import { app, MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface KernZustand {
|
||||
laeuft: boolean
|
||||
pid?: number
|
||||
neustarts: number
|
||||
}
|
||||
|
||||
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
|
||||
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
|
||||
const MAX_NEUSTARTS_JE_MINUTE = 3
|
||||
|
||||
export class KernVerwaltung {
|
||||
private kern: UtilityProcess | null = null
|
||||
private beendenGewollt = false
|
||||
private neustarts = 0
|
||||
private neustartZeiten: number[] = []
|
||||
|
||||
constructor(
|
||||
private readonly datenbankPfad: string,
|
||||
private readonly beiNachricht: (nachricht: KernNachricht) => void,
|
||||
private readonly beiZustand: (zustand: KernZustand) => void,
|
||||
private readonly smokeModus = false,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
const kernDatei = join(__dirname, 'kern.js')
|
||||
const args = [`--datenbank=${this.datenbankPfad}`]
|
||||
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
|
||||
// die Schlüsselkette dann aus.
|
||||
if (this.smokeModus) args.push('--smoke-kern')
|
||||
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
|
||||
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
|
||||
const umgebung: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (app.isPackaged) {
|
||||
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
|
||||
}
|
||||
const kern = utilityProcess.fork(kernDatei, args, {
|
||||
serviceName: 'Rippy-Kern',
|
||||
stdio: 'inherit',
|
||||
env: umgebung,
|
||||
})
|
||||
this.kern = kern
|
||||
|
||||
kern.on('message', (nachricht: unknown) => {
|
||||
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
|
||||
})
|
||||
|
||||
kern.on('spawn', () => {
|
||||
this.beiZustand(this.zustand())
|
||||
})
|
||||
|
||||
kern.on('exit', (code) => {
|
||||
this.kern = null
|
||||
if (this.beendenGewollt) return
|
||||
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
|
||||
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
|
||||
const jetzt = Date.now()
|
||||
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
|
||||
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
|
||||
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
|
||||
this.beiZustand(this.zustand())
|
||||
return
|
||||
}
|
||||
this.neustartZeiten.push(jetzt)
|
||||
this.neustarts += 1
|
||||
this.starten()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
|
||||
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
|
||||
* ins Fenster (§ 4.1).
|
||||
*/
|
||||
fensterVerbinden(webContents: WebContents): void {
|
||||
if (this.kern === null) return
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||
this.kern.postMessage(nachricht, [port1])
|
||||
webContents.postMessage('kern-port', null, [port2])
|
||||
}
|
||||
|
||||
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
|
||||
senden(nachricht: HauptNachricht): void {
|
||||
this.kern?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
zustand(): KernZustand {
|
||||
return {
|
||||
laeuft: this.kern !== null,
|
||||
pid: this.kern?.pid,
|
||||
neustarts: this.neustarts,
|
||||
}
|
||||
}
|
||||
|
||||
beenden(): void {
|
||||
this.beendenGewollt = true
|
||||
this.kern?.kill()
|
||||
this.kern = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
|
||||
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
|
||||
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
|
||||
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
|
||||
//
|
||||
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
|
||||
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
|
||||
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
|
||||
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
|
||||
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
|
||||
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
|
||||
//
|
||||
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
|
||||
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
|
||||
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
|
||||
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
|
||||
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
|
||||
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||
import koffi from 'koffi'
|
||||
|
||||
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||
// bei Offset 16.
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
|
||||
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
|
||||
|
||||
let gesetzt: LeineStatus | null = null
|
||||
|
||||
/**
|
||||
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
|
||||
* VOR dem Start des Kerns — Kinder erben die Mitgliedschaft nur, wenn sie
|
||||
* nach dem Anlegen gestartet werden.
|
||||
*
|
||||
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
|
||||
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
|
||||
*/
|
||||
export function leineSetzen(): LeineStatus {
|
||||
if (gesetzt !== null) return gesetzt
|
||||
try {
|
||||
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()')
|
||||
|
||||
const job = CreateJobObjectW(null, null)
|
||||
if (koffi.address(job) === 0n) {
|
||||
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
|
||||
if (
|
||||
!SetInformationJobObject(
|
||||
job,
|
||||
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
|
||||
info,
|
||||
STRUKTUR_GROESSE,
|
||||
)
|
||||
) {
|
||||
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
|
||||
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
|
||||
return gesetzt
|
||||
}
|
||||
|
||||
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
|
||||
// Prozess-Tod IST der Mechanismus.
|
||||
gesetzt = { ok: true }
|
||||
return gesetzt
|
||||
} catch (fehler) {
|
||||
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
return gesetzt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
|
||||
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
|
||||
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
|
||||
// beendet wird über das Kontextmenü oder den Menüpunkt.
|
||||
import { Menu, nativeImage, Tray } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let tray: Tray | null = null
|
||||
|
||||
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
|
||||
try {
|
||||
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
|
||||
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
|
||||
// per extraResources dorthin).
|
||||
const kandidaten = [
|
||||
join(__dirname, '..', '..', 'bau', 'tray.png'),
|
||||
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
|
||||
]
|
||||
let bild = nativeImage.createEmpty()
|
||||
for (const kandidat of kandidaten) {
|
||||
const versuch = nativeImage.createFromPath(kandidat)
|
||||
if (!versuch.isEmpty()) {
|
||||
bild = versuch
|
||||
break
|
||||
}
|
||||
}
|
||||
tray = new Tray(bild)
|
||||
tray.setToolTip('Rippy — bereit')
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: 'Rippy öffnen', click: oeffnen },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Beenden', click: beenden },
|
||||
]),
|
||||
)
|
||||
tray.on('click', oeffnen)
|
||||
return tray
|
||||
} catch (fehler) {
|
||||
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
|
||||
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function trayHinweis(text: string): void {
|
||||
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
|
||||
}
|
||||
|
||||
export function trayWeg(): void {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
|
||||
// Tests sie ohne Electron laden können.
|
||||
|
||||
export const STANDARD_UPDATE_URL =
|
||||
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
|
||||
|
||||
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1) —
|
||||
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
|
||||
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
|
||||
* akzeptiert. */
|
||||
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
|
||||
const sauber = url.trim()
|
||||
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
|
||||
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
|
||||
return {
|
||||
ok: false,
|
||||
grund:
|
||||
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
|
||||
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
|
||||
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
||||
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
|
||||
// 2. NIE während eines Rips — weder laden noch installieren.
|
||||
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
|
||||
// letzten erfolgreichen Prüfung steht im Status.
|
||||
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
|
||||
import { app } from 'electron'
|
||||
import electronUpdater from 'electron-updater'
|
||||
import { updateUrlPruefen } from './update-regeln'
|
||||
|
||||
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
|
||||
|
||||
export function updateStand(): UpdateStand {
|
||||
return { ...stand }
|
||||
}
|
||||
|
||||
export function updateEinrichten(
|
||||
eigeneUrl: string,
|
||||
ripLaeuft: () => boolean,
|
||||
melden: (text: string) => void,
|
||||
): void {
|
||||
if (!app.isPackaged) {
|
||||
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
|
||||
return
|
||||
}
|
||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||
if (!pruefung.ok) {
|
||||
stand.text = pruefung.grund
|
||||
melden(pruefung.grund)
|
||||
return
|
||||
}
|
||||
if (eigeneUrl.trim().length > 0) {
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||
}
|
||||
autoUpdater.autoDownload = false
|
||||
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
if (ripLaeuft()) {
|
||||
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||
// tägliche Takt versucht es wieder.
|
||||
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
|
||||
return
|
||||
}
|
||||
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
|
||||
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
|
||||
})
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
|
||||
melden(stand.text)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
stand.letzteErfolgreichePruefung = new Date().toISOString()
|
||||
stand.text = 'Rippy ist aktuell.'
|
||||
})
|
||||
autoUpdater.on('error', (fehler) => {
|
||||
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
||||
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
|
||||
})
|
||||
|
||||
const pruefen = (): void => {
|
||||
if (ripLaeuft()) return
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
pruefen()
|
||||
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
|
||||
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
|
||||
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
|
||||
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
|
||||
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
|
||||
|
||||
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
|
||||
|
||||
export async function bibliothekRefresh(
|
||||
server: string,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
holen: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
|
||||
try {
|
||||
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Emby-Token': apiKey },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) {
|
||||
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
|
||||
}
|
||||
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
if (server === 'kodi') {
|
||||
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey.includes(':')) {
|
||||
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
|
||||
}
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
|
||||
method: 'POST',
|
||||
headers: kopf,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
|
||||
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
} catch (fehler) {
|
||||
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
|
||||
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
|
||||
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
|
||||
|
||||
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
|
||||
|
||||
export interface NfoMeta {
|
||||
typ?: 'film' | 'serie'
|
||||
beschreibung?: string
|
||||
genres?: readonly string[]
|
||||
laufzeitMinuten?: number
|
||||
quelle?: string
|
||||
}
|
||||
|
||||
function xml(wert: string): string {
|
||||
return wert
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/** Minimales NFO (pur, testbar). */
|
||||
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
|
||||
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
|
||||
const zeilen = [
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||
`<${wurzel}>`,
|
||||
` <title>${xml(titel ?? '')}</title>`,
|
||||
]
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
|
||||
zeilen.push(` <year>${xml(String(jahr))}</year>`)
|
||||
}
|
||||
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
|
||||
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
|
||||
}
|
||||
for (const genre of meta.genres ?? []) {
|
||||
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
|
||||
}
|
||||
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
||||
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
||||
}
|
||||
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
||||
zeilen.push(`</${wurzel}>`)
|
||||
return zeilen.join('\n') + '\n'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
|
||||
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
|
||||
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
|
||||
// Meldung sagt was passiert ist).
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export function posterUrl(posterPfad: string): string {
|
||||
if (posterPfad.length === 0) return ''
|
||||
if (posterPfad.startsWith('http')) return posterPfad
|
||||
return `https://image.tmdb.org/t/p/w500${posterPfad}`
|
||||
}
|
||||
|
||||
export async function posterSpeichern(
|
||||
posterPfad: string,
|
||||
zielDatei: string,
|
||||
holen: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const url = posterUrl(posterPfad)
|
||||
if (url.length === 0) return ''
|
||||
try {
|
||||
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
|
||||
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
|
||||
const daten = Buffer.from(await antwort.arrayBuffer())
|
||||
if (daten.length === 0) return 'Poster: leere Antwort'
|
||||
writeFileSync(zielDatei, daten)
|
||||
return 'poster.jpg gespeichert'
|
||||
} catch (fehler) {
|
||||
return `Poster fehlgeschlagen: ${String(fehler)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
|
||||
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
|
||||
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
|
||||
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
|
||||
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
|
||||
// #395 war genau das Ratespiel).
|
||||
import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||
// Plattform-Pfadregeln MIT ABSICHT: Diese Funktionen bauen auf
|
||||
// LAUFZEIT-Wurzeln auf (die Ablage-Einstellung) — zur echten Laufzeit ist
|
||||
// das immer Windows, in der Linux-CI testen sie mit POSIX-Ordnern und
|
||||
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
|
||||
// %ProgramFiles%) bleiben dagegen bei path.win32.
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
||||
export function zielordner(
|
||||
basis: string,
|
||||
titel: string,
|
||||
jahr: number | string | null,
|
||||
jobId: string,
|
||||
existiert: (p: string) => boolean = existsSync,
|
||||
): string {
|
||||
const name = sichererName(titel, jahr)
|
||||
if (name.length === 0) return join(basis, jobId)
|
||||
const ziel = join(basis, name)
|
||||
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||
return ziel
|
||||
}
|
||||
|
||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||
export function serienOrdner(basis: string, serie: string, staffel: number): string {
|
||||
const name = sichererName(serie) || 'Unbekannte Serie'
|
||||
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
|
||||
*
|
||||
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in
|
||||
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
|
||||
* passt eine Datei auf mehrere Episoden ähnlich gut → null. */
|
||||
export function matcheEpisoden(
|
||||
dateiSekunden: readonly number[],
|
||||
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
|
||||
toleranzProzent = 8.0,
|
||||
toleranzMinS = 180,
|
||||
): number[] | null {
|
||||
if (dateiSekunden.length === 0 || episoden.length === 0) return null
|
||||
|
||||
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
|
||||
|
||||
if (dateiSekunden.length === episoden.length) {
|
||||
const passt = dateiSekunden.every((dauer, i) => {
|
||||
const epDauer = episoden[i][1]
|
||||
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
|
||||
})
|
||||
if (passt) return episoden.map(([nr]) => nr)
|
||||
}
|
||||
|
||||
const zuordnung: number[] = []
|
||||
let start = 0
|
||||
for (const dauer of dateiSekunden) {
|
||||
const kandidaten = episoden
|
||||
.slice(start)
|
||||
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
|
||||
if (kandidaten.length === 0) return null
|
||||
let gewaehlt = kandidaten[0]
|
||||
if (kandidaten.length > 1) {
|
||||
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
|
||||
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
|
||||
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
|
||||
gewaehlt = sortiert[0]
|
||||
}
|
||||
zuordnung.push(gewaehlt[0])
|
||||
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
|
||||
}
|
||||
return zuordnung
|
||||
}
|
||||
|
||||
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
||||
export function episodenUmbenennen(
|
||||
ordner: string,
|
||||
serie: string,
|
||||
staffel: number,
|
||||
zuordnung: readonly number[],
|
||||
fs: {
|
||||
listen: (o: string) => string[]
|
||||
existiert: (p: string) => boolean
|
||||
umbenennen: (von: string, nach: string) => void
|
||||
} = {
|
||||
listen: (o) => readdirSync(o),
|
||||
existiert: existsSync,
|
||||
umbenennen: renameSync,
|
||||
},
|
||||
): string[] {
|
||||
const meldungen: string[] = []
|
||||
const dateien = fs
|
||||
.listen(ordner)
|
||||
.filter((f) => f.endsWith('.mkv'))
|
||||
.sort()
|
||||
if (dateien.length !== zuordnung.length) {
|
||||
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||
}
|
||||
const serienName = sichererName(serie) || 'Serie'
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const alt = dateien[i]
|
||||
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
|
||||
if (alt === neu) continue
|
||||
const ziel = join(ordner, neu)
|
||||
if (fs.existiert(ziel)) {
|
||||
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
|
||||
continue
|
||||
}
|
||||
fs.umbenennen(join(ordner, alt), ziel)
|
||||
meldungen.push(`${alt} → ${neu}`)
|
||||
}
|
||||
return meldungen
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage.
|
||||
// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über
|
||||
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
|
||||
// hängen damit an der Prozess-Leine (R6).
|
||||
//
|
||||
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
|
||||
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
|
||||
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||
// Einstellung im UI (W-5) eine Stimme.
|
||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
|
||||
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
import { isoSichern } from '../rip/iso'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
abbruch: AbortController
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
export interface PipelineUmgebung {
|
||||
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
|
||||
makemkvcon: () => string
|
||||
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
|
||||
handbrake: () => string
|
||||
/** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */
|
||||
flac: () => string
|
||||
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
||||
audioToc?: (id: string) => Spur[]
|
||||
/** Rohe Audio-Sektoren lesen (W-7). */
|
||||
audioSpurLesen?: (
|
||||
id: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
) => number
|
||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||
geraet: (id: string) => GeraeteInfo | null
|
||||
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||
ablage: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ —
|
||||
* greift nur, wenn der Nutzer nichts eingestellt hat. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||
fingerprint?: (id: string) => string | null
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
}
|
||||
|
||||
export class Pipeline {
|
||||
private laufend = new Map<string, LaufenderVorgang>()
|
||||
|
||||
constructor(private readonly umgebung: PipelineUmgebung) {}
|
||||
|
||||
laeuft(id: string): boolean {
|
||||
return this.laufend.has(id)
|
||||
}
|
||||
|
||||
private melden(status: RipStatus): void {
|
||||
const eintrag = this.laufend.get(status.id)
|
||||
if (eintrag !== undefined) eintrag.status = status
|
||||
this.umgebung.melden(status)
|
||||
}
|
||||
|
||||
private fehler(id: string, text: string): RipStatus {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
}
|
||||
|
||||
const programm = this.umgebung.makemkvcon()
|
||||
if (programm.length === 0) {
|
||||
this.umgebung.melden(
|
||||
this.fehler(
|
||||
id,
|
||||
'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const geraet = this.umgebung.geraet(id)
|
||||
if (geraet === null) {
|
||||
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
|
||||
return
|
||||
}
|
||||
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
|
||||
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
|
||||
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
|
||||
// nie zu „es geht nicht" werden.
|
||||
if (geraet.status === 'empty') {
|
||||
this.umgebung.melden(
|
||||
this.fehler(
|
||||
id,
|
||||
`Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const abbruch = new AbortController()
|
||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
|
||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||
if (geraet.typ === 'cd') {
|
||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||
this.melden(this.laufend.get(id)!.status)
|
||||
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||
return
|
||||
}
|
||||
if (geraet.typ === 'unknown') {
|
||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
||||
this.melden(this.laufend.get(id)!.status)
|
||||
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||
return
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
})
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
return
|
||||
}
|
||||
if (rip.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
const handbrake = this.umgebung.handbrake()
|
||||
const soll = komprimierenFuer(geraet.typ, einstellungen)
|
||||
|
||||
if (!soll || handbrake.length === 0) {
|
||||
const grund = !soll
|
||||
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
|
||||
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text: `Fertig. ${grund}${lesefehlerHinweis}`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
|
||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const fertigOrdner =
|
||||
zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
for (let i = 0; i < rip.dateien.length; i++) {
|
||||
const eingabe = rip.dateien[i]
|
||||
const ausgabe = join(fertigOrdner, basename(eingabe))
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
...alt,
|
||||
phase: 'komprimiert',
|
||||
prozent: -1,
|
||||
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
|
||||
})
|
||||
}
|
||||
const encode = await komprimieren({
|
||||
programm: handbrake,
|
||||
eingabe,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) {
|
||||
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
|
||||
}
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (encode.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
}
|
||||
|
||||
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
|
||||
}
|
||||
try {
|
||||
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
|
||||
writeFileSync(
|
||||
join(fertigOrdner, nfoName),
|
||||
baueNfo(
|
||||
{
|
||||
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
|
||||
beschreibung: zuordnung?.meta.beschreibung,
|
||||
genres: zuordnung?.meta.genres,
|
||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||
},
|
||||
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
zuordnung?.jahr ?? null,
|
||||
),
|
||||
)
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
|
||||
}
|
||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||
}
|
||||
|
||||
const server = String(einstellungen['mediaServer'] ?? '')
|
||||
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||
|
||||
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||
let groesse = 0
|
||||
for (const datei of fertigeDateien) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
|
||||
// am Eintrag selbst.
|
||||
}
|
||||
}
|
||||
this.umgebung.bibliothekEintragen({
|
||||
fingerprint,
|
||||
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
typ: geraet.typ,
|
||||
groesseBytes: groesse,
|
||||
ablageort: fertigOrdner,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text:
|
||||
`Fertig — komprimiert nach ${fertigOrdner}.` +
|
||||
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||
lesefehlerHinweis,
|
||||
dateien: fertigeDateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
}
|
||||
|
||||
abbrechen(id: string): void {
|
||||
this.laufend.get(id)?.abbruch.abort()
|
||||
}
|
||||
|
||||
// ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ──
|
||||
private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise<void> {
|
||||
const fertigMachen = (status: RipStatus): void => {
|
||||
this.laufend.delete(id)
|
||||
this.melden(status)
|
||||
}
|
||||
const flac = this.umgebung.flac()
|
||||
if (flac.length === 0) {
|
||||
fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).'))
|
||||
return
|
||||
}
|
||||
if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) {
|
||||
fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.'))
|
||||
return
|
||||
}
|
||||
let spuren: Spur[]
|
||||
try {
|
||||
spuren = this.umgebung.audioToc(id).filter((s) => s.audio)
|
||||
} catch (fehler) {
|
||||
fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`))
|
||||
return
|
||||
}
|
||||
if (spuren.length === 0) {
|
||||
fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?'))
|
||||
return
|
||||
}
|
||||
|
||||
// MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne
|
||||
// Treffer heißen die Dateien „Track 01" — das ist keine Ausrede,
|
||||
// sondern die Wahrheit über eine Disc, die niemand kennt.
|
||||
const kennung = kennungAusSpuren(spuren)
|
||||
const antwort = await abfragen(kennung)
|
||||
const tags = tagsJeSpur(antwort)
|
||||
const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit)
|
||||
const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName)
|
||||
mkdirSync(ziel, { recursive: true })
|
||||
|
||||
const dateien: string[] = []
|
||||
for (let i = 0; i < spuren.length; i++) {
|
||||
if (abbruch.signal.aborted) {
|
||||
fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false })
|
||||
return
|
||||
}
|
||||
const spur = spuren[i]
|
||||
const spurTags = tags.get(spur.nr) ?? {}
|
||||
const name = dateiname(spur.nr, spurTags['TITLE'] ?? '')
|
||||
const zielDatei = join(ziel, `${name}.flac`)
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) {
|
||||
this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) })
|
||||
}
|
||||
|
||||
// Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang
|
||||
// hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht.
|
||||
const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-'))
|
||||
try {
|
||||
const wav = join(arbeit, 'spur.wav')
|
||||
const strom = createWriteStream(wav)
|
||||
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
||||
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
|
||||
const s = this.laufend.get(id)?.status
|
||||
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
||||
})
|
||||
await new Promise<void>((fertig, kaputt) => {
|
||||
strom.end(() => fertig())
|
||||
strom.on('error', kaputt)
|
||||
})
|
||||
const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags))
|
||||
if (flacErgebnis.code !== 0) {
|
||||
fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`))
|
||||
return
|
||||
}
|
||||
} catch (fehler) {
|
||||
fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`))
|
||||
return
|
||||
} finally {
|
||||
rmSync(arbeit, { recursive: true, force: true })
|
||||
}
|
||||
dateien.push(zielDatei)
|
||||
}
|
||||
|
||||
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
|
||||
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||
let groesse = 0
|
||||
for (const datei of dateien) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Anzeige-Komfort — siehe oben.
|
||||
}
|
||||
}
|
||||
this.umgebung.bibliothekEintragen({
|
||||
fingerprint,
|
||||
titel: ordnerName,
|
||||
jahr: null,
|
||||
typ: 'cd',
|
||||
groesseBytes: groesse,
|
||||
ablageort: ziel,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: '',
|
||||
})
|
||||
}
|
||||
fertigMachen({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''),
|
||||
dateien,
|
||||
lesefehler: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ─────────────────
|
||||
private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise<void> {
|
||||
const ziel = join(this.umgebung.ablage(), 'ISO')
|
||||
mkdirSync(ziel, { recursive: true })
|
||||
const zielDatei = join(ziel, `${vorgangsName}.iso`)
|
||||
const ergebnis = await isoSichern(
|
||||
geraet.pfad,
|
||||
zielDatei,
|
||||
geraet.groesseBytes,
|
||||
(prozent) => {
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) this.melden({ ...stand, prozent })
|
||||
},
|
||||
abbruch.signal,
|
||||
)
|
||||
this.laufend.delete(id)
|
||||
if (ergebnis.status === 'success') {
|
||||
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||
if (fingerprint !== null) {
|
||||
this.umgebung.bibliothekEintragen?.({
|
||||
fingerprint,
|
||||
titel: vorgangsName,
|
||||
jahr: null,
|
||||
typ: 'iso',
|
||||
groesseBytes: ergebnis.bytes,
|
||||
ablageort: zielDatei,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
posterPfad: '',
|
||||
})
|
||||
}
|
||||
this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false })
|
||||
} else if (ergebnis.status === 'cancelled') {
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
} else {
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const stuecke: Buffer[] = []
|
||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
|
||||
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
|
||||
})
|
||||
}
|
||||
|
||||
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
|
||||
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
|
||||
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
|
||||
copyFileSync(eingabe, ausgabe)
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
|
||||
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
|
||||
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
||||
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
||||
//
|
||||
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
||||
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
||||
// Beweis beweise/sqlite-main.js.
|
||||
import type { MessagePortMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
istFensterNachricht,
|
||||
istHauptNachricht,
|
||||
type DbStatus,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import {
|
||||
audioSpurLesen,
|
||||
audioToc,
|
||||
auswerfenMitGrund,
|
||||
geraeteInfo,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
} from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
// nicht — er ist ein reiner Node-Prozess).
|
||||
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--datenbank=')) {
|
||||
const pfad = arg.slice('--datenbank='.length)
|
||||
if (pfad.length > 0) return pfad
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let db: Datenbank | null = null
|
||||
let dbStatus: DbStatus
|
||||
|
||||
const pfad = datenbankPfadAusArgv(process.argv)
|
||||
if (pfad === null) {
|
||||
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
|
||||
} else {
|
||||
try {
|
||||
db = new Datenbank(pfad)
|
||||
// Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis.
|
||||
db.einstellungSchreiben('letzter_start', new Date().toISOString())
|
||||
if (db.einstellungLesen('letzter_start') === null) {
|
||||
dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' }
|
||||
} else {
|
||||
dbStatus = { ok: true, pfad }
|
||||
}
|
||||
} catch (fehler) {
|
||||
// R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt.
|
||||
// Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu
|
||||
// »es geht nicht« werden (§ 6.2, sinngemäß).
|
||||
dbStatus = { ok: false, fehler: String(fehler) }
|
||||
}
|
||||
}
|
||||
|
||||
let fensterPort: MessagePortMain | null = null
|
||||
|
||||
function anFenster(nachricht: KernNachricht): void {
|
||||
fensterPort?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
function anHauptUndFenster(nachricht: KernNachricht): void {
|
||||
process.parentPort.postMessage(nachricht)
|
||||
anFenster(nachricht)
|
||||
}
|
||||
|
||||
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||
let laufwerkApi: LaufwerkApi | null = null
|
||||
let laufwerkFehler: string | null = null
|
||||
let wache: DiscWache | null = null
|
||||
|
||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||
const discZuordnungen = new Map<string, Zuordnung>()
|
||||
const discFingerprints = new Map<string, string>()
|
||||
|
||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||
try {
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||
discFingerprints.set(id, fingerprint)
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: zuordnung.confidence,
|
||||
quelle: zuordnung.meta.quelle,
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
if (datum === undefined || datum.length < 4) return null
|
||||
const jahr = Number(datum.slice(0, 4))
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
async function metadatenSuchen(id: string, text: string): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
if (!tmdb.verfuegbar) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
return
|
||||
}
|
||||
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||
const vorschlaege: MetaVorschlag[] = [
|
||||
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||
typ: 'movie' as const,
|
||||
tmdbId: f.id,
|
||||
titel: f.title ?? '',
|
||||
jahr: jahrAus(f.release_date),
|
||||
posterPfad: f.poster_path ?? '',
|
||||
})),
|
||||
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||
typ: 'tv' as const,
|
||||
tmdbId: s.id,
|
||||
titel: s.name ?? '',
|
||||
jahr: jahrAus(s.first_air_date),
|
||||
posterPfad: s.poster_path ?? '',
|
||||
})),
|
||||
].filter((v) => v.titel.length > 0)
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
}
|
||||
}
|
||||
|
||||
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const details =
|
||||
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
const zuordnung: Zuordnung = {
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta: {
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
beschreibung: details?.overview ?? '',
|
||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||
backdropPfad: details?.backdrop_path ?? '',
|
||||
laufzeitMinuten: details?.runtime ?? 0,
|
||||
genres: (details?.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
},
|
||||
}
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: 1,
|
||||
quelle: 'deine Wahl',
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
const laufwerkeBereit: Promise<void> = (async () => {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||
}
|
||||
const { win32Api } = await import('./laufwerk/win32')
|
||||
laufwerkApi = win32Api()
|
||||
wache = new DiscWache(
|
||||
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||
(meldung) => {
|
||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
discZuordnungen.delete(ereignis.geraet.id)
|
||||
discFingerprints.delete(ereignis.geraet.id)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id: ereignis.geraet.id,
|
||||
titel: '',
|
||||
jahr: null,
|
||||
confidence: 0,
|
||||
quelle: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
beschreibung: '',
|
||||
schonGerippt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
(fehler) => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
|
||||
},
|
||||
)
|
||||
wache.starten()
|
||||
} catch (fehler) {
|
||||
laufwerkFehler = String(fehler)
|
||||
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
|
||||
}
|
||||
})()
|
||||
|
||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
||||
function ablageOrdner(): string {
|
||||
const eingestellt = db?.einstellungLesen('ablage')
|
||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||
}
|
||||
|
||||
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
|
||||
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
|
||||
const PIPELINE_EINSTELLUNGEN = [
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
] as const
|
||||
|
||||
function einstellungenLaden(): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
function geraetVon(id: string): string | null {
|
||||
if (laufwerkApi === null) return null
|
||||
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
||||
}
|
||||
|
||||
const ripVerwaltung = new Pipeline({
|
||||
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
|
||||
audioToc: (id) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioToc(laufwerkApi, geraet)
|
||||
},
|
||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
||||
},
|
||||
geraet: (id) => {
|
||||
if (laufwerkApi === null) return null
|
||||
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
||||
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
||||
},
|
||||
ablage: ablageOrdner,
|
||||
einstellungen: einstellungenLaden,
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||
bibliothekEintragen: (eintrag) => {
|
||||
try {
|
||||
db?.bibliothekEintragen(eintrag)
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} catch (fehler) {
|
||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
||||
// gesagt.
|
||||
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
||||
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
||||
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||
'ablage',
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'makemkvAppKey',
|
||||
'updateUrl',
|
||||
'setup.done',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
'werkzeug.flac',
|
||||
])
|
||||
|
||||
function einstellungenSenden(): void {
|
||||
const werte: Record<string, string> = { ablage: ablageOrdner() }
|
||||
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
// AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine
|
||||
// Einstellungs-Quelle bleibt trotzdem der Kern.
|
||||
anHauptUndFenster({ art: 'einstellungen', werte })
|
||||
}
|
||||
|
||||
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
|
||||
|
||||
async function schluesselLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { schluesselKette } = await import('./werkzeuge/schluessel')
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
schluesselStand = await schluesselKette(
|
||||
makemkvcon,
|
||||
db?.einstellungLesen('makemkvAppKey') ?? '',
|
||||
new Date().toISOString(),
|
||||
)
|
||||
anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||
} catch (fehler) {
|
||||
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
|
||||
console.error(`[kern] Schlüsselkette: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||
let beschaffungLaeuft = false
|
||||
let installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
|
||||
function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
}
|
||||
|
||||
async function makemkvUpdateLauf(): Promise<void> {
|
||||
if (beschaffungLaeuft) {
|
||||
beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||
return
|
||||
}
|
||||
beschaffungLaeuft = true
|
||||
try {
|
||||
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||
'./werkzeuge/beschaffen'
|
||||
)
|
||||
beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||
const neueste = await makemkvNeuesteVersion()
|
||||
if (neueste.length === 0) {
|
||||
beschaffungMelden(
|
||||
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
const installiert = schluesselStand?.version ?? ''
|
||||
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||
// Download derselben Fassung würde nichts lösen.
|
||||
beschaffungMelden(
|
||||
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||
'bis dahin rippt der Beta-Modus weiter.',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||
// erstmals eine).
|
||||
const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) =>
|
||||
beschaffungMelden(text, anteil, true),
|
||||
)
|
||||
beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
installerAntwort = fertig
|
||||
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||
})
|
||||
installerAntwort = null
|
||||
if (startFehler !== null && startFehler.length > 0) {
|
||||
beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
beschaffungMelden(
|
||||
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||
null,
|
||||
true,
|
||||
)
|
||||
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||
let runden = 0
|
||||
const zeiger = setInterval(() => {
|
||||
runden += 1
|
||||
const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
} else if (runden >= 80) {
|
||||
clearInterval(zeiger)
|
||||
beschaffungLaeuft = false
|
||||
beschaffungMelden(
|
||||
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}, 15_000)
|
||||
zeiger.unref()
|
||||
} catch (fehler) {
|
||||
beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||
beschaffungLaeuft = false
|
||||
}
|
||||
}
|
||||
|
||||
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
|
||||
async function werkzeugeSenden(): Promise<void> {
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
|
||||
const { encoderMessen } = await import('./komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||
anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
})
|
||||
}
|
||||
|
||||
async function auswerfen(id: string): Promise<void> {
|
||||
await laufwerkeBereit
|
||||
if (ripVerwaltung.laeuft(id)) {
|
||||
anFenster({
|
||||
art: 'auswurf-ergebnis',
|
||||
id,
|
||||
grund: 'abgelehnt',
|
||||
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (laufwerkApi === null) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
|
||||
return
|
||||
}
|
||||
const geraete = listOptischeLaufwerke(laufwerkApi)
|
||||
const geraet = geraete.find((g) => kennung(g) === id)
|
||||
if (geraet === undefined) {
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||
return
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
|
||||
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||
wache?.runde()
|
||||
}
|
||||
|
||||
process.parentPort.on('message', (ereignis) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
|
||||
installerAntwort?.(nachricht.fehler ?? '')
|
||||
installerAntwort = null
|
||||
return
|
||||
}
|
||||
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
||||
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
||||
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
||||
fensterPort?.close()
|
||||
fensterPort = ereignis.ports[0] ?? null
|
||||
if (fensterPort === null) return
|
||||
fensterPort.on('message', (portEreignis) => {
|
||||
const frage: unknown = portEreignis.data
|
||||
if (!istFensterNachricht(frage)) return
|
||||
if (frage.art === 'ping') {
|
||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
} else if (frage.art === 'auswerfen') {
|
||||
void auswerfen(frage.id)
|
||||
} else if (frage.art === 'rip-start') {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'metadaten-suchen') {
|
||||
void metadatenSuchen(frage.id, frage.text)
|
||||
} else if (frage.art === 'zuordnung-setzen') {
|
||||
void zuordnungSetzen(frage.id, frage.vorschlag)
|
||||
} else if (frage.art === 'einstellung-setzen') {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
|
||||
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
|
||||
} else if (db === null) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
|
||||
} else {
|
||||
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
||||
einstellungenSenden()
|
||||
}
|
||||
} else if (frage.art === 'einstellungen-laden') {
|
||||
einstellungenSenden()
|
||||
} else if (frage.art === 'werkzeuge-laden') {
|
||||
void werkzeugeSenden()
|
||||
} else if (frage.art === 'bibliothek-laden') {
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} else if (frage.art === 'makemkv-update') {
|
||||
void makemkvUpdateLauf()
|
||||
} else if (frage.art === 'schluessel-pruefen') {
|
||||
void schluesselLauf().then(() => werkzeugeSenden())
|
||||
}
|
||||
})
|
||||
fensterPort.start()
|
||||
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
|
||||
anFenster(bereitNachricht())
|
||||
void laufwerkeBereit.then(() => {
|
||||
if (laufwerkFehler !== null) {
|
||||
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
|
||||
}
|
||||
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
|
||||
if (schluesselStand !== null) {
|
||||
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function bereitNachricht(): KernNachricht {
|
||||
return {
|
||||
art: 'kern-bereit',
|
||||
pid: process.pid,
|
||||
nodeVersion: process.versions.node,
|
||||
datenbank: dbStatus,
|
||||
}
|
||||
}
|
||||
|
||||
process.parentPort.postMessage(bereitNachricht())
|
||||
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
|
||||
einstellungenSenden()
|
||||
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
||||
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||
void schluesselLauf()
|
||||
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
||||
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
||||
void werkzeugeSenden()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
|
||||
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
|
||||
//
|
||||
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
|
||||
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
|
||||
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
|
||||
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
||||
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
||||
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
|
||||
import { spawn } from 'node:child_process'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
|
||||
|
||||
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
|
||||
export function parseEncoderListe(hilfeText: string): string[] {
|
||||
const encoder: string[] = []
|
||||
let imBlock = false
|
||||
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
|
||||
const inhalt = zeile.trim()
|
||||
if (!imBlock) {
|
||||
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
|
||||
continue
|
||||
}
|
||||
if (inhalt.length === 0) continue
|
||||
if (inhalt.startsWith('-')) break
|
||||
encoder.push(inhalt.split(/\s+/)[0])
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
|
||||
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
|
||||
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
|
||||
['nvenc', 'nvenc'],
|
||||
['qsv', 'qsv'],
|
||||
['vce', 'vce'],
|
||||
['vaapi', 'vaapi'],
|
||||
]
|
||||
|
||||
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
|
||||
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
|
||||
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
|
||||
const gefunden: string[] = []
|
||||
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
|
||||
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
|
||||
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
|
||||
for (const [kennzeichen, backend] of HW_FAMILIEN) {
|
||||
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
|
||||
if (treffer.length === 0) continue
|
||||
gefunden.push(backend)
|
||||
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
|
||||
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
|
||||
export function parsePresetListe(text: string): string[] {
|
||||
const presets: string[] = []
|
||||
for (const zeile of (text ?? '').split(/\r?\n/)) {
|
||||
if (zeile.trim().length === 0) continue
|
||||
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
|
||||
if (einrueckung === 4) presets.push(zeile.trim())
|
||||
}
|
||||
return presets
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const stuecke: Buffer[] = []
|
||||
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||
kind.on('error', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig('')
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig(textVon(Buffer.concat(stuecke)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface EncoderAuskunft {
|
||||
encoder: string[]
|
||||
backends: string[]
|
||||
presets: string[]
|
||||
}
|
||||
|
||||
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
|
||||
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
|
||||
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
|
||||
const [hilfe, presetListe] = await Promise.all([
|
||||
lauf(programm, ['--help'], 30_000),
|
||||
lauf(programm, ['--preset-list'], 30_000),
|
||||
])
|
||||
const encoder = parseEncoderListe(hilfe)
|
||||
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// HandBrakeCLI ansprechen — Kommandobau, Lauf und die Auswertung mit ALLEN
|
||||
// bezahlten Fallen aus dem Docker-Zweig (ripping.py, jeweils am echten
|
||||
// HandBrake gemessen):
|
||||
//
|
||||
// - `--aencoder`, NICHT `--audio-codec`: Den zweiten Schalter gibt es
|
||||
// nicht, und ein unbekannter Schalter ist für HandBrake KEIN Fehler —
|
||||
// Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip (30.08.2026).
|
||||
// - `--format` aus der Endung erzwingen: Der Container kommt sonst aus
|
||||
// dem PRESET — ein MP4-Preset schreibt title.mp4 neben das verlangte
|
||||
// .mkv und meldet Erfolg (29.08.2026).
|
||||
// - Fortschritt NUR aus der `Encoding: task N of M, x %`-Zeile — der
|
||||
// Scan-Durchlauf läuft vorher bis 100 % und ließ die Anzeige bei 99 %
|
||||
// kleben, während der Encode bei 1 % war (25.07.2026).
|
||||
// - Die letzten 12 Ausgabezeilen aufheben: Bei Rückgabewert 0 sind sie
|
||||
// im Fehlerfall die einzige Auskunft.
|
||||
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||
import { win32 as pfad } from 'node:path'
|
||||
import { textVon } from '../rip/parser'
|
||||
import { FORMATE } from './presets'
|
||||
|
||||
const { basename, dirname, extname, join } = pfad
|
||||
|
||||
export interface HandbrakeArgsOptionen {
|
||||
eingabe: string
|
||||
ausgabe: string
|
||||
preset: string
|
||||
audioSprachen?: readonly string[]
|
||||
untertitelSprachen?: readonly string[]
|
||||
}
|
||||
|
||||
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||
if (format !== undefined) args.push('--format', format)
|
||||
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
||||
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
||||
args.push('--first-audio')
|
||||
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
|
||||
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
||||
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
|
||||
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||
args.push('--all-subtitles')
|
||||
return args
|
||||
}
|
||||
|
||||
// Formatstrings aus dem HandBrakeCLI-Binary gelesen (Regel D):
|
||||
// Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
|
||||
// Encoding: task %d of %d, %.2f %% → Encode
|
||||
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
|
||||
|
||||
/** Encode-Fortschritt 0–100 oder -1 („keine Angabe" — ein echtes 0 % muss
|
||||
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
|
||||
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
|
||||
export function progressAusZeile(zeile: string): number {
|
||||
const treffer = HB_ENCODE.exec(zeile.trim())
|
||||
if (treffer === null) return -1
|
||||
const aktuell = Number(treffer[1])
|
||||
const gesamt = Number(treffer[2])
|
||||
const prozent = Number(treffer[3])
|
||||
if (gesamt < 1 || aktuell < 1) return -1
|
||||
return Math.floor(((aktuell - 1) * 100 + prozent) / gesamt)
|
||||
}
|
||||
|
||||
/** „Invalid preset X" → X (Wortlaut aus dem echten Lauf, 26.07.2026). */
|
||||
export function unbekanntesPreset(zeile: string): string {
|
||||
const text = (zeile ?? '').trim()
|
||||
const kopf = 'Invalid preset '
|
||||
return text.startsWith(kopf) ? text.slice(kopf.length).trim() : ''
|
||||
}
|
||||
|
||||
/** „unknown option (--x)" → --x. Rückgabewert bleibt dabei 0! */
|
||||
export function unbekannterSchalter(zeile: string): string {
|
||||
const treffer = /unknown option \(([^)]*)\)/.exec(zeile ?? '')
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
}
|
||||
|
||||
export const HB_ZEILEN_PUFFER = 12
|
||||
export const HB_ZEILEN_MELDUNG = 4
|
||||
|
||||
/** HandBrakes letzte Worte als Anhang für eine Fehlermeldung (pur). */
|
||||
export function hbSchluss(zeilen: readonly string[]): string {
|
||||
const sauber = zeilen.map((z) => z.trim()).filter((z) => z.length > 0)
|
||||
if (sauber.length === 0) return ''
|
||||
return ' — HandBrake sagte zuletzt: ' + sauber.slice(-HB_ZEILEN_MELDUNG).join(' | ')
|
||||
}
|
||||
|
||||
/** Dieselbe Datei mit anderer Endung im selben Ordner — oder ''. */
|
||||
export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean = existsSync): string {
|
||||
const ordner = dirname(erwartet)
|
||||
const stamm = basename(erwartet, extname(erwartet))
|
||||
for (const endung of Object.keys(FORMATE)) {
|
||||
const kandidat = join(ordner, stamm + endung)
|
||||
if (kandidat !== erwartet && existiert(kandidat)) return kandidat
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
|
||||
export class HbAuswertung {
|
||||
prozent = -1
|
||||
falschesPreset = ''
|
||||
falscherSchalter = ''
|
||||
letzteZeilen: string[] = []
|
||||
|
||||
verarbeite(zeile: string): { prozent?: number } {
|
||||
if (zeile.trim().length > 0) {
|
||||
this.letzteZeilen.push(zeile)
|
||||
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
|
||||
}
|
||||
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
|
||||
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
|
||||
const p = progressAusZeile(zeile)
|
||||
if (p >= 0 && p !== this.prozent) {
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Das Urteil NACH Prozessende — in der bewiesenen Reihenfolge:
|
||||
* falscher Schalter (kommt mit Code 0!) → Erfolg → Datei daneben →
|
||||
* Erfolg ohne Datei → falsches Preset → Code mit letzten Worten. */
|
||||
urteil(
|
||||
returncode: number | null,
|
||||
ausgabe: string,
|
||||
existiert: (p: string) => boolean = existsSync,
|
||||
): { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'error'; fehler: string } {
|
||||
if (this.falscherSchalter.length > 0) {
|
||||
return {
|
||||
status: 'error',
|
||||
fehler:
|
||||
`Rippy hat HandBrake den Schalter „${this.falscherSchalter}" übergeben, den diese ` +
|
||||
'HandBrake-Fassung nicht kennt. Das ist ein Fehler in Rippy, keine Einstellung — bitte melden.' +
|
||||
hbSchluss(this.letzteZeilen),
|
||||
}
|
||||
}
|
||||
if (returncode === 0 && existiert(ausgabe)) {
|
||||
return { status: 'success', ausgabe }
|
||||
}
|
||||
if (returncode === 0) {
|
||||
const daneben = dateiDaneben(ausgabe, existiert)
|
||||
if (daneben.length > 0) {
|
||||
return {
|
||||
status: 'success',
|
||||
ausgabe: daneben,
|
||||
hinweis: `HandBrake hat ${basename(daneben)} geschrieben statt ${basename(ausgabe)} (der Container kommt aus dem Preset).`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
fehler:
|
||||
`HandBrake meldet Erfolg, aber es ist keine Datei entstanden (Zielordner: ${dirname(ausgabe)}).` +
|
||||
hbSchluss(this.letzteZeilen),
|
||||
}
|
||||
}
|
||||
if (this.falschesPreset.length > 0) {
|
||||
return {
|
||||
status: 'error',
|
||||
fehler:
|
||||
`Dieses HandBrake kennt das Preset „${this.falschesPreset}" nicht. Preset-Namen unterscheiden ` +
|
||||
'sich zwischen HandBrake-Versionen — in den Einstellungen eines aus der Liste wählen, die von ' +
|
||||
'diesem HandBrake selbst kommt.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
fehler: `HandBrake endete mit Code ${returncode ?? '?'}` + hbSchluss(this.letzteZeilen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** HandBrake-Ausgabe in Zeilen: CR UND LF trennen (HandBrake schreibt den
|
||||
* Fortschritt mit \r). Je Zeile einzeln dekodiert — HandBrake schreibt
|
||||
* denselben Pfad mal UTF-8, mal CP850 in einen Strom; ein `ü` (CP850
|
||||
* 0x81) tötete so die Python-Fassung. textVon ersetzt statt zu sterben. */
|
||||
export class HbZeilenLeser {
|
||||
private rest: Buffer = Buffer.alloc(0)
|
||||
|
||||
hinzu(stueck: Buffer): string[] {
|
||||
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
|
||||
const zeilen: string[] = []
|
||||
for (;;) {
|
||||
let trenner = -1
|
||||
for (let i = 0; i < this.rest.length; i++) {
|
||||
if (this.rest[i] === 0x0a || this.rest[i] === 0x0d) {
|
||||
trenner = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (trenner < 0) break
|
||||
if (trenner > 0) zeilen.push(textVon(this.rest.subarray(0, trenner)))
|
||||
// \r\n als EIN Trenner behandeln.
|
||||
const naechstes = trenner + 1
|
||||
const doppelt =
|
||||
this.rest[trenner] === 0x0d && naechstes < this.rest.length && this.rest[naechstes] === 0x0a
|
||||
this.rest = this.rest.subarray(doppelt ? naechstes + 1 : naechstes)
|
||||
}
|
||||
return zeilen
|
||||
}
|
||||
|
||||
schluss(): string[] {
|
||||
if (this.rest.length === 0) return []
|
||||
const letzte = textVon(this.rest)
|
||||
this.rest = Buffer.alloc(0)
|
||||
return letzte.trim().length > 0 ? [letzte] : []
|
||||
}
|
||||
}
|
||||
|
||||
export type HbErgebnis =
|
||||
| { status: 'success'; ausgabe: string; hinweis?: string }
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'error'; fehler: string }
|
||||
|
||||
export interface HbLaufOptionen {
|
||||
programm: string
|
||||
eingabe: string
|
||||
ausgabe: string
|
||||
preset: string
|
||||
audioSprachen?: readonly string[]
|
||||
untertitelSprachen?: readonly string[]
|
||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||
extraArgs?: readonly string[]
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||
return new Promise((fertig) => {
|
||||
const args = buildHandbrakeArgs(optionen)
|
||||
args.push(...(optionen.extraArgs ?? []))
|
||||
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
const auswertung = new HbAuswertung()
|
||||
const leser = new HbZeilenLeser()
|
||||
let abgebrochen = false
|
||||
|
||||
const abbrechen = (): void => {
|
||||
abgebrochen = true
|
||||
kind.kill()
|
||||
}
|
||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
kind.on('error', (fehler) => {
|
||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||
fertig({ status: 'error', fehler: String(fehler) })
|
||||
})
|
||||
|
||||
kind.on('close', (code) => {
|
||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||
leser.schluss().forEach(zeile)
|
||||
if (abgebrochen) {
|
||||
fertig({ status: 'cancelled' })
|
||||
return
|
||||
}
|
||||
fertig(auswertung.urteil(code, optionen.ausgabe))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Preset-Wahl je Disc-Typ (KONZEPT § 6.10) — Regeln aus ripping.py, dort
|
||||
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
||||
// 4K auf 1080p herunter und DVDs hinauf.
|
||||
|
||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
|
||||
export { DEFAULT_HB_PRESET, presetEmpfehlung }
|
||||
|
||||
/** Einstellungs-Schlüssel je Disc-Typ. */
|
||||
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
||||
dvd: 'transcodePresetDvd',
|
||||
bluray: 'transcodePresetBluray',
|
||||
uhd: 'transcodePresetUhd',
|
||||
}
|
||||
|
||||
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
||||
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
|
||||
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
|
||||
export const PRESET_KEINE = 'keine'
|
||||
|
||||
export type Einstellungen = Record<string, string | boolean | undefined>
|
||||
|
||||
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
|
||||
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
|
||||
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
||||
return false
|
||||
}
|
||||
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||
if (schluessel.length > 0) {
|
||||
const wert = String(einstellungen[schluessel] ?? '').trim()
|
||||
if (wert === PRESET_KEINE) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
||||
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
|
||||
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
|
||||
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
|
||||
* HandBrake `--preset keine` und scheiterte. */
|
||||
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
||||
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||
if (schluessel.length > 0) {
|
||||
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
||||
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
||||
}
|
||||
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
|
||||
if (allgemein.length > 0) return allgemein
|
||||
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
||||
}
|
||||
|
||||
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
||||
* ein „DEU" aus einem Eingabefeld soll die Auswahl nicht still lahmlegen. */
|
||||
export function sprachliste(wert: string | readonly string[] | null | undefined): string[] {
|
||||
if (wert === null || wert === undefined) return []
|
||||
const teile = typeof wert === 'string' ? wert.split(',') : [...wert]
|
||||
const sauber: string[] = []
|
||||
for (const teil of teile) {
|
||||
const code = String(teil).trim().toLowerCase()
|
||||
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
|
||||
}
|
||||
return sauber
|
||||
}
|
||||
|
||||
/** Endung → HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||
* gegengeprüft (`--help`, Abschnitt `-f, --format`). */
|
||||
export const FORMATE: Record<string, string> = {
|
||||
'.mkv': 'av_mkv',
|
||||
'.mp4': 'av_mp4',
|
||||
'.m4v': 'av_mp4',
|
||||
'.mov': 'av_mov',
|
||||
'.webm': 'av_webm',
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
|
||||
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
|
||||
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
|
||||
// allein in win32.ts (Wächter R1).
|
||||
//
|
||||
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
|
||||
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
|
||||
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
|
||||
// Microsoft-Dokumentation genannten Zahlen gegen.
|
||||
//
|
||||
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
|
||||
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
|
||||
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
|
||||
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
|
||||
// dort an echter Hardware bewiesen).
|
||||
|
||||
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
|
||||
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
|
||||
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
|
||||
}
|
||||
|
||||
const FILE_DEVICE_CD_ROM = 0x02
|
||||
const FILE_DEVICE_DISK = 0x07
|
||||
const FILE_DEVICE_MASS_STORAGE = 0x2d
|
||||
|
||||
const METHOD_BUFFERED = 0
|
||||
const METHOD_OUT_DIRECT = 2
|
||||
|
||||
const FILE_ANY_ACCESS = 0
|
||||
const FILE_READ_ACCESS = 0x0001
|
||||
|
||||
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
|
||||
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
|
||||
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
|
||||
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
|
||||
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
|
||||
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
|
||||
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
|
||||
export const CDROM_TOC_BYTES = 4 + 100 * 8
|
||||
|
||||
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
|
||||
export const SDD_VENDOR_ID_OFFSET = 12
|
||||
export const SDD_PRODUCT_ID_OFFSET = 16
|
||||
export const SDD_PRODUCT_REVISION_OFFSET = 20
|
||||
export const SDD_SERIAL_NUMBER_OFFSET = 24
|
||||
|
||||
export const CDROM_DISK_AUDIO_TRACK = 0x01
|
||||
export const CDROM_DISK_DATA_TRACK = 0x02
|
||||
|
||||
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
|
||||
export const ERROR_INVALID_FUNCTION = 1
|
||||
export const ERROR_FILE_NOT_FOUND = 2
|
||||
export const ERROR_ACCESS_DENIED = 5
|
||||
export const ERROR_NOT_READY = 21
|
||||
export const ERROR_NOT_SUPPORTED = 50
|
||||
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
|
||||
|
||||
export const DRIVE_CDROM = 5
|
||||
|
||||
export const GENERIC_READ = 0x8000_0000
|
||||
export const FILE_SHARE_READ = 0x01
|
||||
export const FILE_SHARE_WRITE = 0x02
|
||||
export const OPEN_EXISTING = 3
|
||||
|
||||
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
|
||||
export class Win32Fehler extends Error {
|
||||
constructor(
|
||||
was: string,
|
||||
readonly code: number,
|
||||
) {
|
||||
super(`${was} scheiterte (Win32-Fehler ${code})`)
|
||||
this.name = 'Win32Fehler'
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
|
||||
export interface LaufwerkApi {
|
||||
laufwerksbuchstaben(): string[]
|
||||
laufwerkstyp(buchstabe: string): number
|
||||
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
||||
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
||||
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
||||
oeffnen(geraetepfad: string, zugriff: number): unknown
|
||||
schliessen(handle: unknown): void
|
||||
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
|
||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
|
||||
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
|
||||
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
|
||||
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
|
||||
// (windows.py, an echter Hardware gemessen 28.08.2026):
|
||||
//
|
||||
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
|
||||
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
|
||||
// Auswurf mit Erfolg und tut nichts (R3).
|
||||
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
|
||||
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
|
||||
import {
|
||||
CDROM_DISK_AUDIO_TRACK,
|
||||
CDROM_DISK_DATA_TRACK,
|
||||
ERROR_ACCESS_DENIED,
|
||||
ERROR_FILE_NOT_FOUND,
|
||||
ERROR_INVALID_FUNCTION,
|
||||
ERROR_NOT_READY,
|
||||
ERROR_NOT_SUPPORTED,
|
||||
ERROR_NO_MEDIA_IN_DRIVE,
|
||||
CDROM_TOC_BYTES,
|
||||
DRIVE_CDROM,
|
||||
GENERIC_READ,
|
||||
IOCTL_CDROM_DISK_TYPE,
|
||||
IOCTL_CDROM_RAW_READ,
|
||||
IOCTL_CDROM_READ_TOC,
|
||||
IOCTL_DISK_GET_LENGTH_INFO,
|
||||
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||
IOCTL_STORAGE_EJECT_MEDIA,
|
||||
IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
SDD_PRODUCT_ID_OFFSET,
|
||||
SDD_PRODUCT_REVISION_OFFSET,
|
||||
SDD_SERIAL_NUMBER_OFFSET,
|
||||
SDD_VENDOR_ID_OFFSET,
|
||||
Win32Fehler,
|
||||
type LaufwerkApi,
|
||||
} from './codes'
|
||||
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
|
||||
import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
|
||||
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
|
||||
|
||||
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
|
||||
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
|
||||
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
|
||||
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
|
||||
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
|
||||
export const UHD_MIN_BYTES = 55 * 1024 ** 3
|
||||
|
||||
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
|
||||
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
|
||||
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
|
||||
if (spurFlags & CDROM_DISK_DATA_TRACK) {
|
||||
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
|
||||
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** „D:" oder „D" oder „D:\" → \\.\D: — der Gerätepfad hat KEINEN
|
||||
* abschließenden Backslash. Mit einem öffnet CreateFileW das
|
||||
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
|
||||
* ERROR_INVALID_FUNCTION — das sieht aus wie „kann das Laufwerk nicht". */
|
||||
export function geraetepfad(buchstabe: string): string {
|
||||
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
|
||||
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
|
||||
return '\\\\.\\' + kern[0].toUpperCase() + ':'
|
||||
}
|
||||
|
||||
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
|
||||
export function kennung(geraet: string): string {
|
||||
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||
}
|
||||
|
||||
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
|
||||
return api
|
||||
.laufwerksbuchstaben()
|
||||
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
|
||||
.map(geraetepfad)
|
||||
.sort()
|
||||
}
|
||||
|
||||
// ── Zustand ─────────────────────────────────────────────────────────────
|
||||
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
|
||||
return true
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
|
||||
return false
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
|
||||
const handle = api.oeffnen(geraet, zugriff)
|
||||
try {
|
||||
return tun(handle)
|
||||
} finally {
|
||||
api.schliessen(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
|
||||
export function spurFlags(api: LaufwerkApi, geraet: string): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
|
||||
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
|
||||
})
|
||||
}
|
||||
|
||||
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
|
||||
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
|
||||
})
|
||||
}
|
||||
|
||||
// ── Verriegeln ──────────────────────────────────────────────────────────
|
||||
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
|
||||
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
|
||||
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
|
||||
mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auswerfen ───────────────────────────────────────────────────────────
|
||||
export type { AuswurfGrund }
|
||||
|
||||
const AUSWURF_NACHSEHEN_RUNDEN = 5
|
||||
|
||||
/** Entriegeln → auswerfen → NACHSEHEN (R3). Wirft NIE; gibt den Grund
|
||||
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
|
||||
* prüfbar ist. */
|
||||
export async function auswerfenMitGrund(
|
||||
api: LaufwerkApi,
|
||||
geraet: string,
|
||||
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
|
||||
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
|
||||
let handle: unknown
|
||||
try {
|
||||
handle = api.oeffnen(geraet, GENERIC_READ)
|
||||
} catch (fehler) {
|
||||
return { grund: 'kein-zugriff', fehler: String(fehler) }
|
||||
}
|
||||
try {
|
||||
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
|
||||
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
|
||||
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
|
||||
let entriegelnFehler: string | null = null
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
|
||||
} catch (fehler) {
|
||||
entriegelnFehler = String(fehler)
|
||||
}
|
||||
|
||||
try {
|
||||
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
|
||||
} catch (fehler) {
|
||||
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||
}
|
||||
|
||||
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
|
||||
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
|
||||
try {
|
||||
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
|
||||
} catch (fehler) {
|
||||
return { grund: 'abgelehnt', fehler: String(fehler) }
|
||||
}
|
||||
await warten(1000)
|
||||
}
|
||||
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
|
||||
} finally {
|
||||
try {
|
||||
api.schliessen(handle)
|
||||
} catch {
|
||||
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
|
||||
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ─────────────────────────────
|
||||
/** Das Inhaltsverzeichnis einer Audio-CD — leere Liste, wenn keine drin.
|
||||
* Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */
|
||||
export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES)
|
||||
return tocZerlegen(roh)
|
||||
})
|
||||
}
|
||||
|
||||
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
||||
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
||||
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
||||
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
|
||||
export function audioSpurLesen(
|
||||
api: LaufwerkApi,
|
||||
geraet: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const teile = bloecke(start, sektoren)
|
||||
let gelesen = 0
|
||||
for (let i = 0; i < teile.length; i++) {
|
||||
const [von, anzahl] = teile[i]
|
||||
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
||||
schreiben(daten)
|
||||
gelesen += daten.length
|
||||
fortschritt?.((i + 1) / teile.length)
|
||||
}
|
||||
return gelesen
|
||||
})
|
||||
}
|
||||
|
||||
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
|
||||
const ZUGRIFFS_GRUENDE: Record<number, string> = {
|
||||
[ERROR_INVALID_FUNCTION]:
|
||||
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
|
||||
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
|
||||
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
|
||||
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
|
||||
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
|
||||
}
|
||||
|
||||
export function zugriffsGrund(fehler: unknown): string {
|
||||
const code = fehler instanceof Win32Fehler ? fehler.code : null
|
||||
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
|
||||
if (code !== null) {
|
||||
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
|
||||
}
|
||||
return 'Das Laufwerk antwortet nicht.'
|
||||
}
|
||||
|
||||
// ── Geräteangaben ───────────────────────────────────────────────────────
|
||||
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
|
||||
* Offset 0 heißt „gibt es nicht" (dokumentiertes Verhalten des
|
||||
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
|
||||
export function textBei(puffer: Buffer, offsetPos: number): string {
|
||||
if (puffer.length < offsetPos + 4) return ''
|
||||
const offset = puffer.readUInt32LE(offsetPos)
|
||||
if (offset === 0 || offset >= puffer.length) return ''
|
||||
let ende = puffer.indexOf(0, offset)
|
||||
if (ende < 0) ende = puffer.length
|
||||
return puffer.toString('latin1', offset, ende).trim()
|
||||
}
|
||||
|
||||
export interface Geraeteangaben {
|
||||
hersteller: string
|
||||
modell: string
|
||||
seriennummer: string
|
||||
}
|
||||
|
||||
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht — ein Laufwerk
|
||||
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
|
||||
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
|
||||
* wenn das Medium gestört ist (rc11-Befund). */
|
||||
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
|
||||
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
|
||||
try {
|
||||
return mitHandle(api, geraet, 0, (h) => {
|
||||
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
|
||||
return {
|
||||
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
|
||||
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
|
||||
.filter((t) => t.length > 0)
|
||||
.join(' '),
|
||||
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return leer
|
||||
}
|
||||
}
|
||||
|
||||
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
|
||||
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
|
||||
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
|
||||
// rippt (Muster aus windows.py).
|
||||
const angabenSpeicher = new Map<string, Geraeteangaben>()
|
||||
|
||||
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
|
||||
let angaben = angabenSpeicher.get(geraet)
|
||||
if (angaben === undefined) {
|
||||
angaben = geraeteangaben(api, geraet)
|
||||
angabenSpeicher.set(geraet, angaben)
|
||||
}
|
||||
return angaben
|
||||
}
|
||||
|
||||
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
|
||||
export function angabenSpeicherLeeren(): void {
|
||||
angabenSpeicher.clear()
|
||||
}
|
||||
|
||||
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
|
||||
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
|
||||
let status: LaufwerkStatus = 'unknown'
|
||||
let typ: DiscTyp = 'unknown'
|
||||
let groesse = 0
|
||||
let grund = ''
|
||||
|
||||
try {
|
||||
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
|
||||
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
|
||||
if (da) {
|
||||
status = 'ready'
|
||||
try {
|
||||
groesse = discGroesseBytes(api, geraet)
|
||||
typ = classify(spurFlags(api, geraet), groesse)
|
||||
} catch (fehler) {
|
||||
typ = 'unknown'
|
||||
grund = zugriffsGrund(fehler)
|
||||
}
|
||||
} else {
|
||||
status = 'empty'
|
||||
}
|
||||
} catch (fehler) {
|
||||
status = 'unknown'
|
||||
grund = zugriffsGrund(fehler)
|
||||
}
|
||||
|
||||
const buchstabe = kennung(geraet)
|
||||
const angaben = angabenGemerkt(api, geraet)
|
||||
return {
|
||||
id: buchstabe,
|
||||
name: `Laufwerk ${buchstabe}:`,
|
||||
typ,
|
||||
pfad: geraet,
|
||||
status,
|
||||
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
|
||||
seriennummer: angaben.seriennummer,
|
||||
groesseBytes: groesse,
|
||||
grund,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
|
||||
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
|
||||
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
|
||||
// bleibt dieser Takt als Rückfallebene bestehen.
|
||||
//
|
||||
// Regeln, die hier verdrahtet sind:
|
||||
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
|
||||
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
|
||||
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
|
||||
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export type { WacheEreignis }
|
||||
|
||||
/** Pure Funktion: Zustandsvergleich → Ereignisse (Muster aus
|
||||
* bus/waechter.py `laufwerks_unterschiede`). */
|
||||
export function laufwerksUnterschiede(
|
||||
vorher: ReadonlyMap<string, GeraeteInfo>,
|
||||
jetzt: ReadonlyMap<string, GeraeteInfo>,
|
||||
): WacheEreignis[] {
|
||||
const ereignisse: WacheEreignis[] = []
|
||||
for (const [id, stand] of jetzt) {
|
||||
const alt = vorher.get(id)
|
||||
if (alt === undefined) {
|
||||
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
|
||||
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||
continue
|
||||
}
|
||||
const vorherBereit = alt.status === 'ready'
|
||||
const jetztBereit = stand.status === 'ready'
|
||||
if (!vorherBereit && jetztBereit) {
|
||||
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
|
||||
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
|
||||
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
|
||||
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
|
||||
// Disc (R2); der Grund steht im Geräte-Eintrag.
|
||||
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
|
||||
}
|
||||
}
|
||||
for (const id of vorher.keys()) {
|
||||
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
|
||||
}
|
||||
return ereignisse
|
||||
}
|
||||
|
||||
export const WACHE_TAKT_MS = 3000
|
||||
|
||||
export interface WacheMeldung {
|
||||
laufwerke: GeraeteInfo[]
|
||||
ereignisse: WacheEreignis[]
|
||||
}
|
||||
|
||||
export class DiscWache {
|
||||
private stand = new Map<string, GeraeteInfo>()
|
||||
private zeiger: NodeJS.Timeout | null = null
|
||||
private laeuft = false
|
||||
|
||||
constructor(
|
||||
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
|
||||
private readonly lesen: () => GeraeteInfo[],
|
||||
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
|
||||
private readonly melden: (meldung: WacheMeldung) => void,
|
||||
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
|
||||
private readonly fehlerMelden: (fehler: unknown) => void,
|
||||
private readonly taktMs: number = WACHE_TAKT_MS,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
if (this.laeuft) return
|
||||
this.laeuft = true
|
||||
this.runde()
|
||||
}
|
||||
|
||||
stoppen(): void {
|
||||
this.laeuft = false
|
||||
if (this.zeiger !== null) {
|
||||
clearTimeout(this.zeiger)
|
||||
this.zeiger = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
|
||||
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||
runde(): void {
|
||||
if (!this.laeuft) return
|
||||
try {
|
||||
const liste = this.lesen()
|
||||
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
|
||||
const geaendert =
|
||||
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
|
||||
this.stand = jetzt
|
||||
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
|
||||
} catch (fehler) {
|
||||
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
|
||||
this.fehlerMelden(fehler)
|
||||
}
|
||||
if (this.laeuft) {
|
||||
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
|
||||
// Der Takt darf einen Programm-Exit nicht aufhalten.
|
||||
this.zeiger.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
aktuellerStand(): GeraeteInfo[] {
|
||||
return [...this.stand.values()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
|
||||
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
|
||||
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
|
||||
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
|
||||
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
|
||||
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
|
||||
// beweise/laufwerk.js, 30.08.2026).
|
||||
import koffi from 'koffi'
|
||||
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
|
||||
|
||||
export type { LaufwerkApi }
|
||||
|
||||
let echteApi: LaufwerkApi | null = null
|
||||
|
||||
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
|
||||
export function win32Api(): LaufwerkApi {
|
||||
if (echteApi !== null) return echteApi
|
||||
|
||||
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)',
|
||||
)
|
||||
|
||||
echteApi = {
|
||||
laufwerksbuchstaben(): string[] {
|
||||
const maske = GetLogicalDrives() as number
|
||||
const buchstaben: string[] = []
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
|
||||
}
|
||||
return buchstaben
|
||||
},
|
||||
|
||||
laufwerkstyp(buchstabe: string): number {
|
||||
return GetDriveTypeW(`${buchstabe}:\\`) as number
|
||||
},
|
||||
|
||||
oeffnen(geraetepfad: string, zugriff: number): unknown {
|
||||
const handle = CreateFileW(
|
||||
geraetepfad,
|
||||
zugriff,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
null,
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
null,
|
||||
)
|
||||
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
|
||||
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
|
||||
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
|
||||
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
|
||||
throw new Win32Fehler('CreateFileW', GetLastError() as number)
|
||||
}
|
||||
return handle
|
||||
},
|
||||
|
||||
schliessen(handle: unknown): void {
|
||||
CloseHandle(handle)
|
||||
},
|
||||
|
||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
||||
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
||||
const zurueck = [0]
|
||||
const erfolg = DeviceIoControl(
|
||||
handle,
|
||||
code,
|
||||
eingabe ?? null,
|
||||
eingabe?.length ?? 0,
|
||||
aus,
|
||||
ausgabeBytes,
|
||||
zurueck,
|
||||
null,
|
||||
)
|
||||
if (!erfolg) {
|
||||
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
|
||||
}
|
||||
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
|
||||
},
|
||||
}
|
||||
return echteApi
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
|
||||
// aus prescan.py, dort an echten Discs gemessen:
|
||||
//
|
||||
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
|
||||
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
|
||||
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
|
||||
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
|
||||
//
|
||||
// KEIN makemkvcon hier: Der Zweig kostete 20–120 s je eingelegter Disc
|
||||
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
|
||||
// sehr sehr lange").
|
||||
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
|
||||
// Plattform-Pfade: titelAusBdmt läuft auf einer LAUFZEIT-Wurzel (G:\ im
|
||||
// Betrieb, ein tmp-Ordner im Linux-CI-Test) — dieselbe Regel wie in
|
||||
// struktur.ts, am 30.08.2026 zum dritten Mal gelernt.
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
|
||||
export function normalizeDiscLabel(label: string): string {
|
||||
if (label.length === 0) return ''
|
||||
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
|
||||
if (bereinigt === bereinigt.toUpperCase()) {
|
||||
return bereinigt
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
|
||||
.join(' ')
|
||||
}
|
||||
return bereinigt
|
||||
}
|
||||
|
||||
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
|
||||
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
|
||||
export function parseUdfDstring(daten: Buffer): string {
|
||||
if (daten.length === 0) return ''
|
||||
const laenge = daten[daten.length - 1]
|
||||
if (laenge < 2 || laenge > daten.length - 1) return ''
|
||||
const compId = daten[0]
|
||||
const text = daten.subarray(1, laenge)
|
||||
if (compId === 8) return text.toString('latin1').trim()
|
||||
if (compId === 16) {
|
||||
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
|
||||
const getauscht = Buffer.alloc(text.length - (text.length % 2))
|
||||
for (let i = 0; i + 1 < text.length; i += 2) {
|
||||
getauscht[i] = text[i + 1]
|
||||
getauscht[i + 1] = text[i]
|
||||
}
|
||||
return getauscht.toString('utf16le').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const SEKTOR = 2048
|
||||
|
||||
function lesenBei(fd: number, position: number, laenge: number): Buffer {
|
||||
const puffer = Buffer.alloc(laenge)
|
||||
const gelesen = readSync(fd, puffer, 0, laenge, position)
|
||||
return puffer.subarray(0, gelesen)
|
||||
}
|
||||
|
||||
/** ISO-9660-Volume-Label: Sektor 16, Bytes 40–71 (DVDs). '' wenn keins. */
|
||||
export function isoVolumeLabel(geraetepfad: string): string {
|
||||
let fd: number
|
||||
try {
|
||||
fd = openSync(geraetepfad, 'r')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
|
||||
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
|
||||
return pvd.subarray(40, 72).toString('latin1').trim()
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256 →
|
||||
* Main VDS → PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
|
||||
export function udfVolumeLabel(geraetepfad: string): string {
|
||||
let fd: number
|
||||
try {
|
||||
fd = openSync(geraetepfad, 'r')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
|
||||
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
|
||||
const vdsOrt = avdp.readUInt32LE(20)
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
|
||||
if (block.length < 56) return ''
|
||||
const tagId = block.readUInt16LE(0)
|
||||
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
|
||||
if (tagId === 8) return '' // Terminating Descriptor
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return ''
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const bdmt =
|
||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmt === undefined) return ''
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
}
|
||||
|
||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||
export function discWurzel(geraetepfad: string): string {
|
||||
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||
const wurzel = buchstabe + ':\\'
|
||||
return existsSync(wurzel) ? wurzel : ''
|
||||
}
|
||||
|
||||
export interface DiscTitel {
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
}
|
||||
|
||||
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
|
||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
||||
export function discTitel(geraetepfad: string): DiscTitel {
|
||||
const wurzel = discWurzel(geraetepfad)
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = titelAusBdmt(wurzel)
|
||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||
return { titel: '', quelle: '' }
|
||||
}
|
||||
|
||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||
* Duplikat-Erkennung in der Bibliothek. */
|
||||
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
|
||||
return `${label}|${groesseBytes}`
|
||||
}
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||
* Klammern, dann hintere Worte strippen. */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
|
||||
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||
}
|
||||
merke(titel)
|
||||
if (titel.includes(':')) {
|
||||
merke(titel.split(':', 1)[0])
|
||||
merke(titel.replaceAll(':', ''))
|
||||
}
|
||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
||||
let worte = titel.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < 6) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
return kandidaten
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user