Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21332e3c7 | ||
|
|
b64ddf5f06 | ||
|
|
c12b1be448 | ||
|
|
b9de2ba729 | ||
|
|
98ca2855e9 | ||
|
|
d5ea48cd1e | ||
|
|
5622d8e47d | ||
|
|
c5dd90cc18 | ||
|
|
0e1c927e7b | ||
|
|
9fa3df500d | ||
|
|
2d9cef22b3 | ||
|
|
c78ff22ab7 | ||
|
|
a9fa8fa2d8 | ||
|
|
5f386e5406 | ||
|
|
e840b7f327 | ||
|
|
3dc296f071 | ||
|
|
ca338ad467 | ||
|
|
844bca37ff | ||
|
|
a45069af5e | ||
|
|
6ed8035dcc | ||
|
|
a172a3e2c0 | ||
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b | ||
|
|
6881a63fb7 | ||
|
|
35370fd552 |
@@ -52,6 +52,17 @@ jobs:
|
|||||||
|
|
||||||
if [ -n "$pkg_dateien" ]; then
|
if [ -n "$pkg_dateien" ]; then
|
||||||
echo "== Node/TypeScript erkannt =="
|
echo "== Node/TypeScript erkannt =="
|
||||||
|
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||||
|
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||||
|
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||||
|
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||||
|
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||||
|
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||||
|
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||||
|
if [ "$node_major" -lt 22 ]; then
|
||||||
|
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
while IFS= read -r pkg; do
|
while IFS= read -r pkg; do
|
||||||
[ -n "$pkg" ] || continue
|
[ -n "$pkg" ] || continue
|
||||||
d=$(dirname "$pkg")
|
d=$(dirname "$pkg")
|
||||||
|
|||||||
@@ -55,3 +55,6 @@ logs/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||||
|
design-entwuerfe/
|
||||||
|
|||||||
+1131
File diff suppressed because it is too large
Load Diff
+1094
-4
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 |
+42
-5
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
|
|||||||
return f"eta:{job_id}"
|
return f"eta:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||||||
|
#:
|
||||||
|
#: ## Der Befund des Commanders (29.08.2026)
|
||||||
|
#:
|
||||||
|
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||||
|
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#:
|
||||||
|
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||||||
|
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||||||
|
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||||||
|
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||||||
|
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||||||
|
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||||||
|
#:
|
||||||
|
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||||||
|
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||||||
|
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||||||
|
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||||||
|
_REIHEN: dict = {}
|
||||||
|
|
||||||
|
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||||||
|
REIHE_HALTBAR_SEKUNDEN = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _aufraeumen(jetzt: float) -> None:
|
||||||
|
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||||||
|
for k, (stand, _) in list(_REIHEN.items()):
|
||||||
|
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||||||
|
_REIHEN.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
jetzt: float, cache_get, cache_set) -> dict:
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||||
keine ETA, aber nichts scheitert.
|
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||||
"""
|
"""
|
||||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
return {"sekunden": -1, "text": ""}
|
return {"sekunden": -1, "text": ""}
|
||||||
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
|||||||
reihe = cache_get(k)
|
reihe = cache_get(k)
|
||||||
except Exception:
|
except Exception:
|
||||||
reihe = None
|
reihe = None
|
||||||
|
if not reihe:
|
||||||
|
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||||||
|
eintrag = _REIHEN.get(k)
|
||||||
|
reihe = eintrag[1] if eintrag else None
|
||||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
try:
|
try:
|
||||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
cache_set(k, reihe, expire=86400)
|
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_REIHEN[k] = (jetzt, reihe)
|
||||||
|
_aufraeumen(jetzt)
|
||||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
|
|||||||
+679
-67
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
["timeout", "3", "ls", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=5
|
||||||
)
|
)
|
||||||
return ergebnis.returncode == 0
|
return ergebnis.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
["timeout", "4", "ls", "-d", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=6
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unklar"
|
return "unklar"
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
|||||||
NICHTS = ""
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
def _meta(meta_json) -> dict:
|
def meta_von(meta_json) -> dict:
|
||||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||||
|
|
||||||
|
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||||
|
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||||
|
"""
|
||||||
if isinstance(meta_json, dict):
|
if isinstance(meta_json, dict):
|
||||||
return meta_json
|
return meta_json
|
||||||
if not meta_json:
|
if not meta_json:
|
||||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
|||||||
"""
|
"""
|
||||||
if (job.get("status") or "") != "failed":
|
if (job.get("status") or "") != "failed":
|
||||||
return NICHTS
|
return NICHTS
|
||||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||||
if marke is True:
|
if marke is True:
|
||||||
return NEU_KOMPRIMIEREN
|
return NEU_KOMPRIMIEREN
|
||||||
if marke is False:
|
if marke is False:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import subprocess
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from rippy import drives as _laufwerks_schicht
|
from rippy import drives as _laufwerks_schicht
|
||||||
from rippy.tools import katalog as werkzeug_katalog
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
from clients.tmdb import TMDBClient
|
from clients.tmdb import TMDBClient
|
||||||
from clients.jikan import JikanClient
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
@@ -25,6 +25,13 @@ from cache.keys import generate_prescan_key
|
|||||||
# test_treiberwahl.py.
|
# test_treiberwahl.py.
|
||||||
detection = _laufwerks_schicht.treiber()
|
detection = _laufwerks_schicht.treiber()
|
||||||
|
|
||||||
|
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
|
||||||
|
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
|
||||||
|
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
|
||||||
|
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
|
||||||
|
# Neuauflagen und Regie-Fassungen desselben Films.
|
||||||
|
WENIGE_TREFFER = 3
|
||||||
|
|
||||||
def normalize_disc_label(label: str) -> str:
|
def normalize_disc_label(label: str) -> str:
|
||||||
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
|
||||||
|
|
||||||
@@ -106,12 +113,14 @@ def disc_wurzel(device_path: str):
|
|||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
return None, lambda: None
|
return None, lambda: None
|
||||||
|
|
||||||
def abhaengen():
|
def abhaengen():
|
||||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
|
||||||
|
creationflags=OHNE_FENSTER)
|
||||||
|
|
||||||
return mountpoint, abhaengen
|
return mountpoint, abhaengen
|
||||||
|
|
||||||
@@ -322,7 +331,8 @@ class PreScan:
|
|||||||
["cdparanoia", "-Q", device_path],
|
["cdparanoia", "-Q", device_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if 'track' in line.lower():
|
if 'track' in line.lower():
|
||||||
@@ -346,31 +356,37 @@ class PreScan:
|
|||||||
if label:
|
if label:
|
||||||
toc["title"] = normalize_disc_label(label)
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 3: die Titelliste von makemkvcon (optional).
|
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||||
#
|
#
|
||||||
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
|
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||||
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
|
#
|
||||||
# Windows liegen Programme in „Programme", nicht im PATH.
|
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||||
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
|
# lange. Das ging mal viel schneller."
|
||||||
# installiertem MakeMKV None zurück, und dieser Zweig lief
|
#
|
||||||
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
|
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||||
# gibt.
|
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||||
makemkv = werkzeug_katalog.finden("makemkv")
|
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||||
if makemkv:
|
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||||
result = subprocess.run(
|
#
|
||||||
[makemkv, "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||||
capture_output=True,
|
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||||
text=True,
|
# `shutil.which`, und das findet unter Windows nichts
|
||||||
timeout=120
|
# (Programme liegen nicht im PATH). `be3fac5` hat das am
|
||||||
)
|
# 28.08.2026 richtig repariert — und damit erst die Kosten
|
||||||
for line in result.stdout.split('\n'):
|
# sichtbar gemacht, die hier immer schon standen.
|
||||||
if line.startswith('TINFO:'):
|
#
|
||||||
parts = line.split(',')
|
# Der Aufwand war umsonst: Das Ergebnis landete allein in
|
||||||
if len(parts) >= 5:
|
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
|
||||||
toc["tracks"].append({
|
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
# und `GET /devices/{id}/tracks` — also dann, wenn sie
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
|
||||||
})
|
# Der Weg fuer eine Disc, die man gar nicht rippen will,
|
||||||
|
# sind so zwei Minuten Warten fuer nichts.
|
||||||
|
#
|
||||||
|
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
|
||||||
|
# billig. Wer den Aufruf je wieder braucht, braucht dazu
|
||||||
|
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
|
||||||
|
# `tools.katalog` — die Importe sind mit ihm gegangen.
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
@@ -447,11 +463,13 @@ class PreScan:
|
|||||||
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
# probieren — Disc-Titel sind selten API-freundlich formatiert.
|
||||||
kandidaten = titel_kandidaten(title) or [title]
|
kandidaten = titel_kandidaten(title) or [title]
|
||||||
movies = []
|
movies = []
|
||||||
|
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
|
||||||
|
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
treffer = self.tmdb.search_movie(kandidat)
|
treffer = self.tmdb.search_movie(kandidat)
|
||||||
if treffer and not movies:
|
if treffer and not movies:
|
||||||
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
|
||||||
|
movies_kandidat = kandidat
|
||||||
for movie in treffer:
|
for movie in treffer:
|
||||||
if movie.get("title", "").lower() == kandidat.lower():
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
confidence = 0.95
|
confidence = 0.95
|
||||||
@@ -498,6 +516,55 @@ class PreScan:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
|
||||||
|
#
|
||||||
|
# ## Der Befund des Commanders (28.08.2026)
|
||||||
|
#
|
||||||
|
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
|
||||||
|
# > wir ein cover."
|
||||||
|
#
|
||||||
|
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
|
||||||
|
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
|
||||||
|
# == kandidat.lower()`. An seiner Disc gemessen:
|
||||||
|
#
|
||||||
|
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||||
|
# 'Evangelion' 20 Treffer irgendein Evangelion
|
||||||
|
#
|
||||||
|
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
|
||||||
|
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
|
||||||
|
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
|
||||||
|
#
|
||||||
|
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
|
||||||
|
#
|
||||||
|
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
|
||||||
|
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
|
||||||
|
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
|
||||||
|
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
|
||||||
|
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
|
||||||
|
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
|
||||||
|
#
|
||||||
|
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
|
||||||
|
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
|
||||||
|
# ehrlich unter einem wörtlichen Treffer (0,95).
|
||||||
|
if not matched and movies and movies_kandidat == kandidaten[0] \
|
||||||
|
and len(movies) <= WENIGE_TREFFER:
|
||||||
|
details = self.tmdb.get_movie_details(movies[0]["id"])
|
||||||
|
if details:
|
||||||
|
confidence = 0.8
|
||||||
|
metadata = {
|
||||||
|
"type": "movie",
|
||||||
|
"id": movies[0]["id"],
|
||||||
|
"title": details.get("title", title),
|
||||||
|
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
|
||||||
|
"overview": details.get("overview", ""),
|
||||||
|
"poster_path": details.get("poster_path", ""),
|
||||||
|
"backdrop_path": details.get("backdrop_path", ""),
|
||||||
|
"runtime": details.get("runtime", 0),
|
||||||
|
"genres": [g["name"] for g in details.get("genres", [])],
|
||||||
|
"source": "tmdb",
|
||||||
|
}
|
||||||
|
matched = True
|
||||||
|
|
||||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
|
||||||
#
|
#
|
||||||
|
|||||||
+137
-15
@@ -38,18 +38,71 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
|||||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
from rippy import pfade as _pfade
|
||||||
|
|
||||||
|
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||||||
|
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||||||
RAW_STANDARD = "/app/temp/raw"
|
RAW_STANDARD = "/app/temp/raw"
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_einstellungen() -> dict:
|
||||||
|
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||||
|
gerade nicht antwortet.
|
||||||
|
|
||||||
|
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||||
|
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||||
|
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||||
|
Vorgabe, wie bisher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rippy import store as db
|
||||||
|
|
||||||
|
return db.get_settings(bei_fehler_leer=True) or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def wurzeln(werte=None) -> tuple:
|
||||||
|
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||||
|
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||||
|
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||||
|
`/app/media`.
|
||||||
|
|
||||||
|
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||||
|
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||||
|
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||||
|
4K-UHD bis zu 100 GB.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
|
||||||
|
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
|
||||||
|
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
|
||||||
|
# was eingestellt ist (Befund 30.08.2026).
|
||||||
|
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
|
||||||
|
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||||
|
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||||
|
|
||||||
|
|
||||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||||
|
|
||||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||||
@@ -65,13 +118,37 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
"""
|
"""
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return []
|
return []
|
||||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||||
wahl = (work_dir or "").strip().rstrip("/")
|
from rippy import pfade
|
||||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
|
||||||
orte.append(posixpath.join(wahl, job_id))
|
orte = [pfade.verbinden(roh, job_id)]
|
||||||
|
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
|
||||||
|
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
|
||||||
|
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
|
||||||
|
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
|
||||||
|
#
|
||||||
|
# "F:\\".rstrip("/\\") -> "F:"
|
||||||
|
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
|
||||||
|
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
|
||||||
|
#
|
||||||
|
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
|
||||||
|
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
|
||||||
|
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
|
||||||
|
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||||
|
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
|
||||||
|
wahl = (work_dir or "").strip()
|
||||||
|
vergleich = wahl.rstrip("/\\")
|
||||||
|
grenze = (medien or "").rstrip("/\\")
|
||||||
|
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||||
|
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||||
|
if vergleich and (frei or vergleich == grenze
|
||||||
|
or vergleich.startswith(grenze + "/")):
|
||||||
|
orte.append(pfade.verbinden(wahl, job_id))
|
||||||
for name in media_unterordner or []:
|
for name in media_unterordner or []:
|
||||||
if name:
|
if name:
|
||||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||||
gesehen, eindeutig = set(), []
|
gesehen, eindeutig = set(), []
|
||||||
for ort in orte:
|
for ort in orte:
|
||||||
if ort not in gesehen:
|
if ort not in gesehen:
|
||||||
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def nativ_nachsehen() -> bool:
|
||||||
|
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||||
|
|
||||||
|
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||||
|
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||||
|
`pruefen`.
|
||||||
|
"""
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def pruefen(pfad: str, laufen=None) -> str:
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
|||||||
"""
|
"""
|
||||||
if not pfad:
|
if not pfad:
|
||||||
return "weg"
|
return "weg"
|
||||||
|
if laufen is None and nativ_nachsehen():
|
||||||
|
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
#
|
||||||
|
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||||
|
# 14:54:40 WindowsTerminal.exe
|
||||||
|
#
|
||||||
|
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||||
|
# gehen ganz kurz auf und dann wieder zu."
|
||||||
|
#
|
||||||
|
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||||
|
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||||
|
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||||
|
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||||
|
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||||
|
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||||
|
# unter Windows grundsaetzlich unsichtbar.
|
||||||
|
#
|
||||||
|
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||||
|
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||||
|
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||||
|
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||||
|
try:
|
||||||
|
return "da" if os.path.isdir(pfad) else "weg"
|
||||||
|
except OSError:
|
||||||
|
return "unklar"
|
||||||
starten = laufen or subprocess.run
|
starten = laufen or subprocess.run
|
||||||
try:
|
try:
|
||||||
ergebnis = starten(
|
ergebnis = starten(
|
||||||
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
|||||||
return pruefen(pfad, laufen) == "da"
|
return pruefen(pfad, laufen) == "da"
|
||||||
|
|
||||||
|
|
||||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Die Orte, an denen wirklich etwas liegt.
|
"""Die Orte, an denen wirklich etwas liegt.
|
||||||
|
|
||||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||||
@@ -156,12 +271,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||||
"""
|
"""
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden = []
|
gefunden = []
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
try:
|
try:
|
||||||
if isdir(ort):
|
if isdir(ort):
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
|
||||||
|
orte_wurzeln=None) -> dict:
|
||||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||||
|
|
||||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||||
@@ -181,12 +298,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
|||||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||||
"""
|
"""
|
||||||
pruefe = pruefer or pruefen
|
pruefe = pruefer or pruefen
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden, unklar = [], False
|
gefunden, unklar = [], False
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
antwort = pruefe(ort)
|
antwort = pruefe(ort)
|
||||||
if antwort == "da":
|
if antwort == "da":
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for name in namen:
|
for name in namen:
|
||||||
voll = posixpath.join(pfad, name)
|
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||||||
|
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||||||
|
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||||||
|
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||||||
|
voll = _pfade.verbinden(pfad, name)
|
||||||
try:
|
try:
|
||||||
if isfile(voll):
|
if isfile(voll):
|
||||||
bytes_gesamt += getsize(voll)
|
bytes_gesamt += getsize(voll)
|
||||||
|
|||||||
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
|||||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||||
monkeypatch.setattr(main.db, "get_settings", lambda: {})
|
#
|
||||||
|
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||||
|
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||||
|
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||||
|
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||||
|
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||||
|
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||||
|
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||||
|
|
||||||
zustand = asyncio.run(main._snapshot())
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
@@ -401,8 +409,526 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
|||||||
assert daten["modus"] in ("standalone", "verteilt")
|
assert daten["modus"] in ("standalone", "verteilt")
|
||||||
assert daten["plattform"] in ("windows", "linux", "macos")
|
assert daten["plattform"] in ("windows", "linux", "macos")
|
||||||
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||||
"container_pfade", "werkzeuge_verwalten"}
|
"container_pfade", "werkzeuge_verwalten",
|
||||||
|
"frei_blaettern"}
|
||||||
|
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
|
||||||
|
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
|
||||||
|
# der keinen Container hat (Befund 28.08.2026).
|
||||||
|
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
|
||||||
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
assert all(isinstance(w, bool) for w in daten["kann"].values())
|
||||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||||
if not daten["im_container"]:
|
if not daten["im_container"]:
|
||||||
assert daten["hilfe_befehl"] == ""
|
assert daten["hilfe_befehl"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
|
||||||
|
#
|
||||||
|
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||||
|
#
|
||||||
|
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
|
||||||
|
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
|
||||||
|
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
|
||||||
|
# auch `running` auf `processing` ab.
|
||||||
|
#
|
||||||
|
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
|
||||||
|
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
|
||||||
|
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
|
||||||
|
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
|
||||||
|
|
||||||
|
|
||||||
|
def _echte_db_zeile():
|
||||||
|
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "j1",
|
||||||
|
"disc_type": "bluray", # NICHT "type"
|
||||||
|
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
|
||||||
|
"finished_at": None,
|
||||||
|
"status": "running", # wird zu "processing"
|
||||||
|
"device": r"\.\G:",
|
||||||
|
"progress": 12,
|
||||||
|
"title": "Evangelion 2.22",
|
||||||
|
"error": None,
|
||||||
|
"meta": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
|
||||||
|
import main
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
assert ui["type"] == "bluray"
|
||||||
|
assert ui["startTime"].startswith("2026-08-29")
|
||||||
|
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
|
||||||
|
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
|
||||||
|
Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft."""
|
||||||
|
import main
|
||||||
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
kurz = _job_kurz(ui)
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
|
||||||
|
assert kurz["type"] == "bluray"
|
||||||
|
assert kurz["status"] == "processing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
|
||||||
|
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
|
||||||
|
assert "_job_row_to_model" in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
|
||||||
|
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
|
||||||
|
|
||||||
|
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
|
||||||
|
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
|
||||||
|
(beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der
|
||||||
|
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
|
||||||
|
weiter oben stand.
|
||||||
|
|
||||||
|
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
# Die eine erlaubte Fundstelle ist die Funktion selbst.
|
||||||
|
stellen = quelle.count("device_discovery.device_info(")
|
||||||
|
assert stellen == 1, (
|
||||||
|
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
|
||||||
|
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
|
||||||
|
for name in ("_snapshot", "startup_event", "get_devices"):
|
||||||
|
text = inspect.getsource(getattr(main, name))
|
||||||
|
assert "device_info(" not in text, \
|
||||||
|
"%s baut die Laufwerksliste selbst" % name
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_mit_disc()
|
||||||
|
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
|
||||||
|
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
assert "disc" not in main.laufwerke_mit_disc()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
|
||||||
|
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
|
||||||
|
sein."
|
||||||
|
|
||||||
|
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg —
|
||||||
|
damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und
|
||||||
|
einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
|
||||||
|
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
|
||||||
|
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
|
||||||
|
(gemessen: Disc nach 119 s erkannt).
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert geraet["disc_wird_erkannt"] is True
|
||||||
|
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
|
||||||
|
assert "disc" not in geraet
|
||||||
|
|
||||||
|
|
||||||
|
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert not geraet.get("disc_wird_erkannt")
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
|
||||||
|
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
|
||||||
|
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
assert "disc_wird_erkannt" in main.Device.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
|
||||||
|
#
|
||||||
|
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
|
||||||
|
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
|
||||||
|
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
|
||||||
|
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
|
||||||
|
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||||
|
# sein soll.
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready"}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
|
||||||
|
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
|
||||||
|
„behalte deinen Stand", und das ist die ehrliche Antwort."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
|
assert main.laufwerke_notdurft() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
|
||||||
|
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
|
||||||
|
nur das Abfragen des Laufwerks ist teuer."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
|
||||||
|
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready",
|
||||||
|
"disc_wird_erkannt": True}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_notdurft()[0]
|
||||||
|
assert "disc_wird_erkannt" not in geraet
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
|
||||||
|
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
|
||||||
|
# fenster ganz kurz im hintergrund."
|
||||||
|
#
|
||||||
|
# Beides war dieselbe Schleife: `except OSError: continue` liess
|
||||||
|
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
|
||||||
|
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
|
||||||
|
# naechste Statusabfrage scheitert — und so weiter.
|
||||||
|
|
||||||
|
|
||||||
|
def _CDS():
|
||||||
|
import main
|
||||||
|
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, ok) == "erststart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
|
||||||
|
import main
|
||||||
|
_, leer, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, leer) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
|
||||||
|
"""Der Kern des Befundes."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
|
||||||
|
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
|
||||||
|
„noch nie gesehen" verwechselt, scannt endlos.
|
||||||
|
|
||||||
|
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
|
||||||
|
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
|
||||||
|
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
|
||||||
|
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
|
||||||
|
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
|
||||||
|
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_wird_erkannt():
|
||||||
|
import main
|
||||||
|
ok, leer, offen = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
|
||||||
|
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
|
||||||
|
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
|
||||||
|
weg, sobald das Laufwerk kurz beschaeftigt ist."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, 3) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
|
||||||
|
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.disc_watcher)
|
||||||
|
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
|
||||||
|
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
|
||||||
|
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
gemessen' aber messung wird nicht abgeschlossen."
|
||||||
|
|
||||||
|
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
|
||||||
|
seine Jobs mit dem nackten `_job_row_to_model` — also ohne. Seit V2-3
|
||||||
|
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
|
||||||
|
also brav berechnet und niemandem gezeigt.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main._snapshot)
|
||||||
|
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
|
||||||
|
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_jobliste_traegt_die_restzeit_felder():
|
||||||
|
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
|
||||||
|
assert feld in main.Job.model_fields, feld
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
|
||||||
|
#
|
||||||
|
# Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
|
||||||
|
# bereits bei 30% und er liest immernoch."
|
||||||
|
#
|
||||||
|
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
|
||||||
|
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
|
||||||
|
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
|
||||||
|
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
|
||||||
|
# wissen wir — der Job laeuft ja auf ihr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gescannt = []
|
||||||
|
|
||||||
|
class NieBenutzt:
|
||||||
|
def scan(self, pfad):
|
||||||
|
gescannt.append(pfad)
|
||||||
|
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", NieBenutzt)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert gescannt == []
|
||||||
|
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
class Ergebnis:
|
||||||
|
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"title": "Akira"}
|
||||||
|
|
||||||
|
class Scanner:
|
||||||
|
def scan(self, pfad):
|
||||||
|
return Ergebnis()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", Scanner)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
|
||||||
|
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
|
||||||
|
lambda p: asyncio.sleep(0))
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
|
||||||
|
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
|
||||||
|
Ende stehen — der Scan davor haelt sie ja fest."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
|
||||||
|
if not quelle:
|
||||||
|
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
|
||||||
|
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
|
||||||
|
#
|
||||||
|
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
|
||||||
|
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
|
||||||
|
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
|
||||||
|
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
|
||||||
|
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
|
||||||
|
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
|
||||||
|
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
|
||||||
|
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
|
||||||
|
|
||||||
|
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
|
||||||
|
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_offen_zaehlt_die_kompression_mit():
|
||||||
|
from rippy.store import JOB_OFFEN
|
||||||
|
|
||||||
|
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
|
||||||
|
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
|
||||||
|
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
||||||
|
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
|
||||||
|
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
|
||||||
|
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from rippy import store
|
||||||
|
|
||||||
|
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
|
||||||
|
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
|
||||||
|
|
||||||
|
12:49:52 bluray-Rip gestartet
|
||||||
|
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||||
|
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
|
||||||
|
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
|
||||||
|
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
|
||||||
|
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
|
||||||
|
stand = main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
|
||||||
|
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
|
||||||
|
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
|
||||||
|
main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == ["/dev/sr0"]
|
||||||
|
|||||||
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
|||||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
assert speicher == {} # nichts geschrieben
|
assert speicher == {} # nichts geschrieben
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#
|
||||||
|
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
|
||||||
|
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
|
||||||
|
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
|
||||||
|
# Punkt, und `restzeit_sekunden` braucht zwei.
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_cache():
|
||||||
|
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
|
||||||
|
return (lambda k: None), (lambda k, v, expire=None: False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
|
||||||
|
"""DER Fall des Commanders."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
|
||||||
|
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
|
||||||
|
|
||||||
|
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
|
||||||
|
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_cache_hat_weiterhin_vorrang():
|
||||||
|
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
|
||||||
|
API-Neustart, das Woerterbuch im Prozess nicht."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"j2", "running", 25, 620.0,
|
||||||
|
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
|
||||||
|
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
|
||||||
|
assert ergebnis["sekunden"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
|
||||||
|
"""Rip und Kompression haben nichts miteinander zu tun."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
|
||||||
|
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
|
||||||
|
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alte_reihen_wachsen_nicht_unbegrenzt():
|
||||||
|
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
|
||||||
|
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
|
||||||
|
eta.aktualisiere_und_schaetze("neu", "running", 10,
|
||||||
|
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
|
||||||
|
assert eta.schluessel("alt") not in eta._REIHEN
|
||||||
|
assert eta.schluessel("neu") in eta._REIHEN
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
from makemkv_key import extract_key
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||||
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
|
|||||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
assert treffer == _KEY
|
assert treffer == _KEY
|
||||||
assert treffer.startswith("T-")
|
assert treffer.startswith("T-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
|
||||||
|
#
|
||||||
|
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
|
||||||
|
# > ausgelesen werden, ich glaube das web rippy kann das"
|
||||||
|
#
|
||||||
|
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
|
||||||
|
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
|
||||||
|
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def klient():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
daten = antwort.json()
|
||||||
|
assert daten["geholt"] is True
|
||||||
|
assert daten["geaendert"] is True
|
||||||
|
assert daten["endet_auf"] == _KEY[-6:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
|
||||||
|
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
|
||||||
|
ein zweiter Weg, ihn zu verlieren."""
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
text = klient.post("/system/makemkv-key/holen").text
|
||||||
|
assert _KEY not in text
|
||||||
|
assert _KEY[:20] not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
|
||||||
|
monkeypatch):
|
||||||
|
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
|
||||||
|
def wirft(*a, **k):
|
||||||
|
raise OSError("Verbindung abgelehnt")
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "Forum" in antwort.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
|
||||||
|
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
|
||||||
|
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "geändert" in antwort.json()["detail"]
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Welche Pfade darf die API anfassen — und wo ist „oben"?
|
||||||
|
|
||||||
|
## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026)
|
||||||
|
|
||||||
|
`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container
|
||||||
|
ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und
|
||||||
|
die Folge war eine Oberflaeche, die stillschweigend nichts konnte:
|
||||||
|
|
||||||
|
* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht),
|
||||||
|
* `/browse` antwortete auf JEDEN Pfad mit 422,
|
||||||
|
* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag.
|
||||||
|
|
||||||
|
Sein Befund dazu: „Wäre es möglich das Arbeitsverzeichnis zu ändern?
|
||||||
|
momentan geht das nicht."
|
||||||
|
|
||||||
|
Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im
|
||||||
|
Netz — eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein
|
||||||
|
Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem
|
||||||
|
Rechner sitzt. Diese Tests halten beide Haelften fest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Grenze gilt, wo zugehoert wird ──────────────────────────────────
|
||||||
|
def test_im_container_gilt_die_wurzel_weiter():
|
||||||
|
"""Der Docker-Weg darf sich durch die Reparatur NICHT lockern."""
|
||||||
|
assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False)
|
||||||
|
assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False)
|
||||||
|
# Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb.
|
||||||
|
assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ist_auch_eine_freigabe_erlaubt():
|
||||||
|
"""Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar
|
||||||
|
keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar."""
|
||||||
|
assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True)
|
||||||
|
assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leerer_pfad_ist_nie_erlaubt():
|
||||||
|
"""Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`."""
|
||||||
|
assert not main.pfad_erlaubt("", "/app/media", frei=True)
|
||||||
|
assert not main.pfad_erlaubt("", "/app/media", frei=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wo ist oben ─────────────────────────────────────────────────────────
|
||||||
|
def test_an_der_wurzel_ist_schluss():
|
||||||
|
"""Im Container. `None` heisst: kein Knopf „nach oben"."""
|
||||||
|
assert main.eltern_von("/app/media", "/app/media", frei=False) is None
|
||||||
|
assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste():
|
||||||
|
"""Zwei Fallen auf einmal.
|
||||||
|
|
||||||
|
`os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` — ein Knopf „nach oben",
|
||||||
|
der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der
|
||||||
|
Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke:
|
||||||
|
Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer
|
||||||
|
100 GB Rohdaten.
|
||||||
|
"""
|
||||||
|
import ntpath
|
||||||
|
import os
|
||||||
|
|
||||||
|
echt = os.path.dirname
|
||||||
|
os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux
|
||||||
|
try:
|
||||||
|
assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == ""
|
||||||
|
assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users"
|
||||||
|
finally:
|
||||||
|
os.path.dirname = echt
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
||||||
|
"""Eine leere Wurzel wuerde jede Pruefung durchwinken."""
|
||||||
|
assert main.medien_wurzel()
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
|
||||||
|
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
|
||||||
|
assert isinstance(main.betrieb_laufwerke(), list)
|
||||||
@@ -197,10 +197,16 @@ def test_englisch_wird_bevorzugt(tmp_path):
|
|||||||
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||||
|
|
||||||
|
|
||||||
def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||||
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei
|
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||||
installiertem MakeMKV None zurueck -- Windows-Programme liegen in
|
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||||
„Programme", nicht im PATH. Dieser Zweig lief dort also NIE."""
|
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
|
import os
|
||||||
|
|
||||||
hier = os.path.dirname(os.path.abspath(__file__))
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
|||||||
quelle = "".join(code)
|
quelle = "".join(code)
|
||||||
assert "shutil.which(" not in quelle, (
|
assert "shutil.which(" not in quelle, (
|
||||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||||
assert 'werkzeug_katalog.finden("makemkv")' in quelle
|
assert "makemkvcon" not in quelle, (
|
||||||
|
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||||
|
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (28.08.2026)
|
||||||
|
|
||||||
|
> „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
|
||||||
|
> cover."
|
||||||
|
|
||||||
|
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
|
||||||
|
sondern eine Bedingung im Pre-Scan:
|
||||||
|
|
||||||
|
if movie.get("title", "").lower() == kandidat.lower():
|
||||||
|
|
||||||
|
An seiner Disc gemessen:
|
||||||
|
|
||||||
|
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
|
||||||
|
'Evangelion' 20 Treffer irgendein Evangelion
|
||||||
|
|
||||||
|
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
|
||||||
|
Poster — und wurde verworfen, weil der Titel nicht woertlich gleich war.
|
||||||
|
|
||||||
|
## Warum die Trefferzahl und nicht die Aehnlichkeit
|
||||||
|
|
||||||
|
„Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
|
||||||
|
Aehnlichkeit — das steht als Messung schon laenger im Code und faellt durch
|
||||||
|
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
|
||||||
|
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
|
||||||
|
weiss was er sucht. Wer 20 bekommt, hat geraten.
|
||||||
|
|
||||||
|
Diese Tests spritzen die Clients ein — kein Netz, keine Schluessel, keine
|
||||||
|
Disc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTMDB:
|
||||||
|
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
|
||||||
|
|
||||||
|
def __init__(self, treffer_je_suche, details=None):
|
||||||
|
self.treffer = treffer_je_suche
|
||||||
|
self.details = details or {}
|
||||||
|
self.gefragt = []
|
||||||
|
|
||||||
|
def search_movie(self, begriff):
|
||||||
|
self.gefragt.append(begriff)
|
||||||
|
return self.treffer.get(begriff, [])
|
||||||
|
|
||||||
|
def search_tv(self, begriff):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_movie_details(self, kennung):
|
||||||
|
return self.details.get(kennung)
|
||||||
|
|
||||||
|
def get_tv_details(self, kennung):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_by_imdb(self, kennung):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StummerClient:
|
||||||
|
def lookup(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DETAILS_2_0 = {
|
||||||
|
22843: {
|
||||||
|
"title": "Evangelion: 2.0 You Can (Not) Advance",
|
||||||
|
"release_date": "2009-06-27",
|
||||||
|
"overview": "Der Pilotin Mari gelingt es …",
|
||||||
|
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
|
||||||
|
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
|
||||||
|
"runtime": 112,
|
||||||
|
"genres": [{"name": "Animation"}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prescan(tmdb):
|
||||||
|
from prescan.prescan import PreScan
|
||||||
|
|
||||||
|
p = PreScan()
|
||||||
|
p.tmdb = tmdb
|
||||||
|
p.jikan = StummerClient()
|
||||||
|
p.omdb = StummerClient()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _toc(titel):
|
||||||
|
return {"title": titel, "year": None, "tracks": [], "duration": 0,
|
||||||
|
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
|
||||||
|
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Evangelion 2.22": [{"id": 22843,
|
||||||
|
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
|
||||||
|
DETAILS_2_0)
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||||
|
|
||||||
|
assert ergebnis.confidence == 0.8
|
||||||
|
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
|
||||||
|
assert ergebnis.year == 2009
|
||||||
|
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
|
||||||
|
assert ergebnis.metadata["source"] == "tmdb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_woertlich_gleicher_titel_bleibt_sicherer():
|
||||||
|
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Logan": [{"id": 22843, "title": "Logan"}]},
|
||||||
|
{22843: dict(DETAILS_2_0[22843], title="Logan")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
|
||||||
|
assert ergebnis.confidence == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
|
||||||
|
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
|
||||||
|
werden. Es bleibt beim ehrlichen Vorschlag."""
|
||||||
|
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
|
||||||
|
tmdb = FakeTMDB({"Evangelion": viele},
|
||||||
|
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
|
||||||
|
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
|
||||||
|
|
||||||
|
|
||||||
|
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
|
||||||
|
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
|
||||||
|
|
||||||
|
Sonst wuerde „Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
|
||||||
|
„Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
|
||||||
|
"""
|
||||||
|
tmdb = FakeTMDB(
|
||||||
|
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
|
||||||
|
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
|
||||||
|
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
|
||||||
|
assert ergebnis.confidence == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
|
||||||
|
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
|
||||||
|
assert ergebnis.confidence == 0.3
|
||||||
|
assert ergebnis.metadata["type"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
|
||||||
|
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
|
||||||
|
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
|
||||||
|
desselben Films, aber eng genug, um Raten auszuschliessen."""
|
||||||
|
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
|
||||||
|
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
|
||||||
|
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
|
||||||
|
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
|
||||||
|
assert ergebnis.confidence == erwartet
|
||||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
|||||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||||
|
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||||
|
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||||
|
# prompt sieben Tests rot geworden).
|
||||||
|
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||||
|
|
||||||
|
|
||||||
def test_suche_liefert_nur_was_existiert():
|
def test_suche_liefert_nur_was_existiert():
|
||||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["movies", "rippy"],
|
listdir=lambda p: ["movies", "rippy"],
|
||||||
isdir=lambda p: p in vorhanden,
|
isdir=lambda p: p in vorhanden,
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
|||||||
assert f"/app/media/{JOB}" in orte
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||||
|
#: Gegenstück zu CONTAINER weiter oben.
|
||||||
|
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||||
|
|
||||||
|
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||||
|
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||||
|
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||||
|
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||||
|
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||||
|
beschädigten Datenträger für etwas, das schon dalag.
|
||||||
|
"""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||||
|
assert "F:" + chr(92) + JOB in orte
|
||||||
|
assert "F:" + JOB not in orte
|
||||||
|
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||||
|
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||||
|
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||||
|
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||||
|
nicht am Trenner scheitern."""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||||
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_job_id_nichts():
|
def test_ohne_job_id_nichts():
|
||||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||||
|
|
||||||
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
|||||||
return p == f"/app/temp/raw/{JOB}"
|
return p == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
|||||||
raise OSError("kein /app/media")
|
raise OSError("kein /app/media")
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
|||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
|||||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [], "unklar": True}
|
assert e == {"pfade": [], "unklar": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
|
|||||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
|||||||
return "unklar" if "totes-nas" in pfad else "weg"
|
return "unklar" if "totes-nas" in pfad else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
assert e["unklar"] is True
|
assert e["unklar"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
|
||||||
|
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
|
||||||
|
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
|
||||||
|
14:54:40 WindowsTerminal.exe
|
||||||
|
|
||||||
|
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
|
||||||
|
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
|
||||||
|
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
|
||||||
|
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
|
||||||
|
die Antwort weg fuer JEDES Verzeichnis.
|
||||||
|
"""
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: gestartet.append(a))
|
||||||
|
assert rohdaten.pruefen(str(tmp_path)) == "da"
|
||||||
|
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
|
||||||
|
assert gestartet == [], "es darf kein Prozess gestartet werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_bleibt_der_kindprozess(monkeypatch):
|
||||||
|
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
|
||||||
|
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
class Antwort:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
|
||||||
|
assert rohdaten.pruefen("/app/media/x") == "da"
|
||||||
|
assert aufrufe[0][0] == "timeout"
|
||||||
|
assert aufrufe[0][-1] == "/app/media/x"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
|
|||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||||
import { BetriebProvider } from './lib/useBetrieb'
|
import { BetriebProvider } from './lib/useBetrieb'
|
||||||
|
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
@@ -161,10 +162,19 @@ function AppInhalt() {
|
|||||||
|
|
||||||
{/* Main Content Viewport */}
|
{/* Main Content Viewport */}
|
||||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||||
|
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||||
|
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||||
|
Bildschirm leer — samt Kopfzeile. */}
|
||||||
|
<Fehlergrenze bereich={
|
||||||
|
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||||
|
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||||
|
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||||
{currentPage === 'dashboard' && <Dashboard />}
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
{currentPage === 'logs' && <LogsPage />}
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
|
</Fehlergrenze>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -178,10 +188,12 @@ export default function App() {
|
|||||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||||
return (
|
return (
|
||||||
|
<Fehlergrenze>
|
||||||
<BetriebProvider>
|
<BetriebProvider>
|
||||||
<EventStreamProvider>
|
<EventStreamProvider>
|
||||||
<AppInhalt />
|
<AppInhalt />
|
||||||
</EventStreamProvider>
|
</EventStreamProvider>
|
||||||
</BetriebProvider>
|
</BetriebProvider>
|
||||||
|
</Fehlergrenze>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface Device {
|
|||||||
serial?: string
|
serial?: string
|
||||||
model?: string
|
model?: string
|
||||||
disc?: DiscInfo
|
disc?: DiscInfo
|
||||||
|
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
|
||||||
|
disc_wird_erkannt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function posterUrl(disc?: DiscInfo): string | null {
|
function posterUrl(disc?: DiscInfo): string | null {
|
||||||
@@ -179,6 +181,34 @@ export default function DeviceDiscovery() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{/*
|
||||||
|
Die Erkennung LÄUFT NOCH — das muss man sehen.
|
||||||
|
|
||||||
|
Commander 29.08.2026: „das die disc erkennung noch läuft muss
|
||||||
|
sichtbar sein". An einer Blu-ray dauert sie rund zwei Minuten
|
||||||
|
(`makemkvcon info` läuft in seine 120-Sekunden-Grenze; gemessen:
|
||||||
|
Disc nach 119 s erkannt). Vorher war in dieser Zeit NICHTS zu
|
||||||
|
sehen — weder die Disc noch ein Hinweis. Das sah aus wie ein
|
||||||
|
leeres Laufwerk, also wie ein Fehler.
|
||||||
|
*/}
|
||||||
|
{devices.filter(d => d.disc_wird_erkannt).map(device => (
|
||||||
|
<div
|
||||||
|
key={`erkennung-${device.id}`}
|
||||||
|
className="mb-6 rounded-2xl border border-slate-700 bg-slate-900/60 px-5 py-4 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className="animate-spin text-amber-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-200">
|
||||||
|
Disc wird gelesen — {device.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis
|
||||||
|
zu zwei Minuten; danach steht hier, was drin ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Eine Fehlergrenze — damit ein Fehler nicht die ganze Oberfläche verschluckt.
|
||||||
|
*
|
||||||
|
* ## Der Befund des Commanders (29.08.2026)
|
||||||
|
*
|
||||||
|
* > „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||||
|
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||||
|
* > auch keine fehlermeldung."
|
||||||
|
*
|
||||||
|
* Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
*
|
||||||
|
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
*
|
||||||
|
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
|
||||||
|
* 12 %). Nur zeigte die Oberfläche das nie — sie war weg. Grund: React baut
|
||||||
|
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
|
||||||
|
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
|
||||||
|
* einzige Fehlergrenze.
|
||||||
|
*
|
||||||
|
* ## Warum die eigentliche Reparatur woanders sitzt
|
||||||
|
*
|
||||||
|
* Die Ursache war ein Ereignis, das den halben Job schickte
|
||||||
|
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
|
||||||
|
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
|
||||||
|
*
|
||||||
|
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
|
||||||
|
* mitnehmen — und schon gar nicht schweigend. Der nächste Fehler dieser Art
|
||||||
|
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
|
||||||
|
* Oberfläche bleibt bedienbar.
|
||||||
|
*
|
||||||
|
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
|
||||||
|
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
/** Was hier kaputtgehen kann — steht in der Meldung. */
|
||||||
|
bereich?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
fehler: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Fehlergrenze extends Component<Props, State> {
|
||||||
|
state: State = { fehler: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(fehler: Error): State {
|
||||||
|
return { fehler }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(fehler: Error, info: ErrorInfo) {
|
||||||
|
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
|
||||||
|
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
|
||||||
|
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
|
||||||
|
fehler, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
neuLaden = () => {
|
||||||
|
this.setState({ fehler: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.fehler) return this.props.children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
|
||||||
|
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
|
||||||
|
{this.props.bereich
|
||||||
|
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
|
||||||
|
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
|
||||||
|
Der Fehler betrifft nur die Anzeige.{' '}
|
||||||
|
<strong>Laufende Rips gehen weiter</strong> — Rippy arbeitet im
|
||||||
|
Hintergrund, auch wenn dieser Bereich leer bleibt.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
|
||||||
|
{this.state.fehler.message || String(this.state.fehler)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={this.neuLaden}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
Nochmal versuchen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Seite neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
|
|||||||
<CardTitle>Live-Log</CardTitle>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{currentJob
|
||||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
|
||||||
|
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
|
||||||
|
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
|
||||||
|
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
|
||||||
: 'Kein aktiver Job'}
|
: 'Kein aktiver Job'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Ein Pfadfeld mit „Durchsuchen …" — wie im Installer.
|
||||||
|
*
|
||||||
|
* ## Der Wunsch des Commanders (29.08.2026)
|
||||||
|
*
|
||||||
|
* > „Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das
|
||||||
|
* > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt.
|
||||||
|
* > Wie es der Installer auch macht"
|
||||||
|
*
|
||||||
|
* Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt
|
||||||
|
* man den Ordner, danach musste man ihn abtippen. Einen Pfad wie
|
||||||
|
* `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig.
|
||||||
|
*
|
||||||
|
* ## Warum eine eigene Datei
|
||||||
|
*
|
||||||
|
* Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre:
|
||||||
|
* `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in
|
||||||
|
* ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen.
|
||||||
|
*
|
||||||
|
* Der Browser selbst ist die API — `/browse` weiß seit dem 28.08.2026, in
|
||||||
|
* welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste
|
||||||
|
* der Laufwerke. Diese Komponente muss darüber nichts wissen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ArrowUp, Folder, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input } from './ui/Input'
|
||||||
|
|
||||||
|
interface Eintrag {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
onChange: (pfad: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) {
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const [pfad, setPfad] = useState('')
|
||||||
|
const [eltern, setEltern] = useState<string | null>(null)
|
||||||
|
const [ordner, setOrdner] = useState<Eintrag[]>([])
|
||||||
|
const [fehler, setFehler] = useState('')
|
||||||
|
const [laedt, setLaedt] = useState(false)
|
||||||
|
|
||||||
|
const laden = async (ziel: string) => {
|
||||||
|
setLaedt(true)
|
||||||
|
setFehler('')
|
||||||
|
try {
|
||||||
|
const r = await api.get('/browse', { params: { path: ziel } })
|
||||||
|
setPfad(r.data.path || '')
|
||||||
|
setEltern(r.data.parent)
|
||||||
|
setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
// Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen —
|
||||||
|
// man kommt mit „nach oben" wieder heraus.
|
||||||
|
setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar')
|
||||||
|
setOrdner([])
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offen) return
|
||||||
|
// Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt
|
||||||
|
// für /browse „oberste Ebene" — nativ also die Laufwerksliste.
|
||||||
|
laden(value || '')
|
||||||
|
}, [offen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => setOffen(true)}>
|
||||||
|
Durchsuchen …
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={offen}
|
||||||
|
onClose={() => setOffen(false)}
|
||||||
|
title={label}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
|
<button
|
||||||
|
onClick={() => eltern !== null && laden(eltern)}
|
||||||
|
disabled={eltern === null}
|
||||||
|
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||||
|
title="Eine Ebene höher"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
|
||||||
|
{pfad || 'Laufwerke'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
|
||||||
|
{laedt && (
|
||||||
|
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen …</p>
|
||||||
|
)}
|
||||||
|
{!laedt && fehler && (
|
||||||
|
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
|
||||||
|
)}
|
||||||
|
{!laedt && !fehler && ordner.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
|
||||||
|
)}
|
||||||
|
{ordner.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.path}
|
||||||
|
onClick={() => laden(o.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Folder size={14} className="text-slate-400" />
|
||||||
|
{o.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<FolderOpen size={13} />
|
||||||
|
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setOffen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
disabled={!pfad}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(pfad)
|
||||||
|
setOffen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diesen Ordner nutzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Modal } from './ui/Modal'
|
|||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Input, Select } from './ui/Input'
|
import { Input, Select } from './ui/Input'
|
||||||
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
|
||||||
|
import { useBetrieb } from '../lib/useBetrieb'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -157,12 +158,20 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [scanStatus, deviceId])
|
}, [scanStatus, deviceId])
|
||||||
|
|
||||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
const betrieb = useBetrieb()
|
||||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
|
||||||
])
|
/*
|
||||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
|
||||||
|
*
|
||||||
|
* Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
|
||||||
|
* Windows-PC gibt es die nicht — und fuer den Augenblick zwischen Oeffnen
|
||||||
|
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
|
||||||
|
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
|
||||||
|
*/
|
||||||
|
const [targets, setTargets] = useState<TargetConfig[]>([])
|
||||||
|
const [browsePath, setBrowsePath] = useState<string>('')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
@@ -181,6 +190,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
|
||||||
const [arbeitsDir, setArbeitsDir] = useState('')
|
const [arbeitsDir, setArbeitsDir] = useState('')
|
||||||
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
|
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
|
||||||
|
// Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in
|
||||||
|
// der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt
|
||||||
|
// — und stünde leer da, obwohl etwas gewählt ist.
|
||||||
|
const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -194,6 +207,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
|
setEigenesArbeitsZiel('')
|
||||||
setBrowserOffen(false)
|
setBrowserOffen(false)
|
||||||
setDiscSprachen(null)
|
setDiscSprachen(null)
|
||||||
setAudioWahl(new Set())
|
setAudioWahl(new Set())
|
||||||
@@ -213,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
|
||||||
setStandardAudio(codes(s.audioSprachen))
|
setStandardAudio(codes(s.audioSprachen))
|
||||||
setStandardUntertitel(codes(s.untertitelSprachen))
|
setStandardUntertitel(codes(s.untertitelSprachen))
|
||||||
const basis = s.outputDir || '/app/media'
|
const basis = s.outputDir || betrieb.ablage_vorgabe
|
||||||
setTargets([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||||
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
|
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
|
||||||
@@ -240,6 +254,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
|
||||||
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
|
||||||
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
|
||||||
|
/*
|
||||||
|
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
|
||||||
|
*
|
||||||
|
* Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er
|
||||||
|
* holt sich das Arbeitsverzeichnis ja von der Installation."
|
||||||
|
*
|
||||||
|
* Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)",
|
||||||
|
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
|
||||||
|
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
|
||||||
|
*/
|
||||||
|
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
|
||||||
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
|
||||||
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
|
||||||
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
|
||||||
@@ -378,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
onChange={e => setArbeitsDir(e.target.value)}
|
onChange={e => setArbeitsDir(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Standard aus den Einstellungen
|
Standard{arbeitsVorgabe ? ` — ${arbeitsVorgabe}` : ''}
|
||||||
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
|
|
||||||
</option>
|
</option>
|
||||||
{arbeitsZiele.map(z => (
|
{arbeitsZiele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<option key={z.path} value={z.path}>
|
||||||
@@ -387,10 +411,13 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{eigenesArbeitsZiel && (
|
||||||
|
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={13} />
|
<HardDrive size={13} />
|
||||||
Bei 4K-UHD bis zu 100 GB — nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
|
Bei 4K-UHD bis zu 100 GB — nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben.
|
||||||
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,12 +615,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const auf = !browserOffen
|
const auf = !browserOffen
|
||||||
setBrowserOffen(auf)
|
setBrowserOffen(auf)
|
||||||
if (auf && browseDirs.length === 0) laden('/app/media')
|
if (auf && browseDirs.length === 0) laden('')
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||||
>
|
>
|
||||||
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
Anderen Zielordner wählen (nur für diesen Rip)
|
{selectedType === 'music'
|
||||||
|
? 'Anderen Zielordner wählen (nur für diesen Rip)'
|
||||||
|
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{browserOffen && (
|
{browserOffen && (
|
||||||
@@ -607,14 +636,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
>
|
>
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{browsePath || 'Laufwerke'}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={!browsePath}
|
||||||
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
|
||||||
>
|
>
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
|
||||||
|
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
|
||||||
|
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
|
||||||
|
beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar
|
||||||
|
nicht („momentan geht das nicht", 28.08.2026). */}
|
||||||
|
{selectedType !== 'music' && (
|
||||||
|
<Button
|
||||||
|
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!browsePath}
|
||||||
|
onClick={() => {
|
||||||
|
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
|
||||||
|
setEigenesArbeitsZiel(browsePath)
|
||||||
|
setArbeitsDir(browsePath)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-36 overflow-y-auto p-1">
|
<div className="max-h-36 overflow-y-auto p-1">
|
||||||
{browseDirs.map(d => (
|
{browseDirs.map(d => (
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||||
|
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
|
||||||
|
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
|
||||||
|
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
|
||||||
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
label: type.toUpperCase(),
|
label: type ? type.toUpperCase() : 'DISC',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface BetriebsFaehigkeiten {
|
|||||||
container_pfade: boolean
|
container_pfade: boolean
|
||||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||||
werkzeuge_verwalten: boolean
|
werkzeuge_verwalten: boolean
|
||||||
|
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||||
|
frei_blaettern: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Betrieb {
|
export interface Betrieb {
|
||||||
@@ -54,6 +56,8 @@ export interface Betrieb {
|
|||||||
kann: BetriebsFaehigkeiten
|
kann: BetriebsFaehigkeiten
|
||||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||||
ablage_vorgabe: string
|
ablage_vorgabe: string
|
||||||
|
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
||||||
|
arbeits_vorgabe: string
|
||||||
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||||
hilfe_befehl: string
|
hilfe_befehl: string
|
||||||
@@ -71,8 +75,13 @@ const VORGABE: Betrieb = {
|
|||||||
freigaben_einhaengen: true,
|
freigaben_einhaengen: true,
|
||||||
container_pfade: true,
|
container_pfade: true,
|
||||||
werkzeuge_verwalten: false,
|
werkzeuge_verwalten: false,
|
||||||
|
frei_blaettern: false,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: '/app/media',
|
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||||
|
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
|
||||||
|
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
|
||||||
|
ablage_vorgabe: '',
|
||||||
|
arbeits_vorgabe: '',
|
||||||
hilfe_befehl: 'docker compose -p rippy ps',
|
hilfe_befehl: 'docker compose -p rippy ps',
|
||||||
geladen: false,
|
geladen: false,
|
||||||
}
|
}
|
||||||
@@ -100,8 +109,10 @@ export function BetriebProvider({ children }: { children: ReactNode }) {
|
|||||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||||
container_pfade: !!d.kann.container_pfade,
|
container_pfade: !!d.kann.container_pfade,
|
||||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||||
|
frei_blaettern: !!d.kann.frei_blaettern,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||||
|
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||||
hilfe_befehl: d.hilfe_befehl || '',
|
hilfe_befehl: d.hilfe_befehl || '',
|
||||||
geladen: true,
|
geladen: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ interface Job {
|
|||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
api_version: string
|
api_version: string
|
||||||
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean;
|
||||||
|
frei_gb: number; gesamt_gb: number }[]
|
||||||
workers: { name: string; encoders: string[] }[]
|
workers: { name: string; encoders: string[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ interface LaufwerkLive {
|
|||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||||
|
/** Die Erkennung laeuft noch — es gibt noch keinen Titel, aber auch kein
|
||||||
|
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
|
||||||
|
disc_wird_erkannt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||||
@@ -328,6 +332,12 @@ export default function Dashboard() {
|
|||||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||||
: discImLaufwerk
|
: discImLaufwerk
|
||||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||||
|
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
|
||||||
|
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
|
||||||
|
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
|
||||||
|
// Fehler (Commander 29.08.2026).
|
||||||
|
: laufwerke.some(l => l.disc_wird_erkannt)
|
||||||
|
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
|
||||||
: 'Bereit — kein Datenträger im Laufwerk'
|
: 'Bereit — kein Datenträger im Laufwerk'
|
||||||
|
|
||||||
const filteredJobs = jobs.filter(j => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
@@ -584,6 +594,38 @@ export default function Dashboard() {
|
|||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Abbrechen DIREKT in der Zeile (Commander
|
||||||
|
29.08.2026: „man kann einen rip garnicht
|
||||||
|
abbrechen").
|
||||||
|
|
||||||
|
Es gab genau einen Abbrechen-Knopf, und der
|
||||||
|
steckte in der Kachel „laufender Job". Die
|
||||||
|
erschien nur bei Status `processing` — der
|
||||||
|
Ereignisstrom lieferte aber den rohen
|
||||||
|
DB-Status `running`. Also keine Kachel, also
|
||||||
|
kein Knopf, also kein Weg zurueck.
|
||||||
|
Die Ursache ist behoben; ein zweiter Weg zum
|
||||||
|
Abbrechen ist trotzdem richtig, denn genau
|
||||||
|
hier sucht man ihn.
|
||||||
|
*/}
|
||||||
|
{['processing', 'transcoding', 'pending'].includes(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => api.post(`/jobs/${job.id}/cancel`)
|
||||||
|
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
|
||||||
|
.catch((e: any) => toast('error',
|
||||||
|
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === 'canceling' && (
|
||||||
|
<span className="text-xs font-mono text-slate-400">bricht ab …</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{job.status === 'completed' && (
|
{job.status === 'completed' && (
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
<Download size={13} /> Download
|
<Download size={13} /> Download
|
||||||
@@ -749,10 +791,45 @@ export default function Dashboard() {
|
|||||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
{/*
|
||||||
|
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
|
||||||
|
‚Platz für rippy' sollte eher das arbeitsverzeichnis und
|
||||||
|
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
|
||||||
|
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
|
||||||
|
*/}
|
||||||
|
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
|
||||||
|
<div key={ort.name + ort.pfad} className="mb-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={14} className="text-amber-400" />
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'}
|
{ort.name}:
|
||||||
|
</span>
|
||||||
|
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
|
||||||
|
? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
|
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ort.pfad && (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
|
||||||
|
{ort.pfad}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!betrieb.kann.container_pfade
|
||||||
|
&& (systemInfo?.plaetze || []).length > 1
|
||||||
|
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
|
||||||
|
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
|
||||||
|
statt die zweite Zeile wegzulassen. */
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 mb-1">
|
||||||
|
Beide auf demselben Laufwerk — der Platz wird geteilt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
|
||||||
|
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
|
Container-Platte:
|
||||||
</span>
|
</span>
|
||||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from '../components/ui/PageHeader'
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
|
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||||
@@ -213,6 +214,9 @@ export default function SettingsPage() {
|
|||||||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||||||
|
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
|
||||||
|
const [keyHolt, setKeyHolt] = useState(false)
|
||||||
|
const [keyMeldung, setKeyMeldung] = useState('')
|
||||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
@@ -253,6 +257,43 @@ export default function SettingsPage() {
|
|||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
|
||||||
|
*
|
||||||
|
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
|
||||||
|
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
|
||||||
|
* zwei Wege, ihn zu verlieren.
|
||||||
|
*/
|
||||||
|
const keyJetztHolen = async () => {
|
||||||
|
setKeyHolt(true)
|
||||||
|
setKeyMeldung('')
|
||||||
|
try {
|
||||||
|
const r = await api.post('/system/makemkv-key/holen')
|
||||||
|
const antwort = await api.get('/settings')
|
||||||
|
setSettings({ ...defaultSettings, ...antwort.data })
|
||||||
|
if (r.data?.angenommen === false) {
|
||||||
|
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
|
||||||
|
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
|
||||||
|
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
|
||||||
|
// trotzdem.
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
|
||||||
|
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
|
||||||
|
} else {
|
||||||
|
setKeyMeldung(r.data?.geaendert
|
||||||
|
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||||||
|
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||||||
|
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const grund = e?.response?.data?.detail
|
||||||
|
|| 'Das Forum ist nicht erreichbar.'
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + grund)
|
||||||
|
toast('error', grund)
|
||||||
|
} finally {
|
||||||
|
setKeyHolt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keydbHochladen = async (datei: File) => {
|
const keydbHochladen = async (datei: File) => {
|
||||||
setKeydbBusy(true)
|
setKeydbBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -940,15 +981,40 @@ export default function SettingsPage() {
|
|||||||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||||||
inklusive freiem Platz.
|
inklusive freiem Platz.
|
||||||
*/}
|
*/}
|
||||||
|
{!betrieb.kann.container_pfade ? (
|
||||||
|
/*
|
||||||
|
* Ein echtes Pfadfeld statt einer Auswahl — dieselbe
|
||||||
|
* Entscheidung wie bei der Ablage darüber, und aus demselben
|
||||||
|
* Grund.
|
||||||
|
*
|
||||||
|
* Commander am 28.08.2026: „Wäre es möglich das
|
||||||
|
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||||||
|
*
|
||||||
|
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
|
||||||
|
* kommt aus /storage-targets, und das las `/app/media` — ein
|
||||||
|
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
|
||||||
|
* genau einem Eintrag, und der hieß „Container-Platte".
|
||||||
|
*
|
||||||
|
* Die Liste ist inzwischen repariert (sie bringt jetzt die
|
||||||
|
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
|
||||||
|
* „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
|
||||||
|
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||||
|
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||||
|
*/
|
||||||
|
<OrdnerWaehler
|
||||||
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
|
value={settings.workDir}
|
||||||
|
placeholder={betrieb.arbeits_vorgabe}
|
||||||
|
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{betrieb.kann.container_pfade
|
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||||||
? 'Container-Platte (Standard) — klein, nur für DVD/Blu-ray'
|
|
||||||
: `Standard (${betrieb.ablage_vorgabe || 'neben der Ablage'})`}
|
|
||||||
</option>
|
</option>
|
||||||
{ziele.map(z => (
|
{ziele.map(z => (
|
||||||
<option key={z.path} value={z.path}>
|
<option key={z.path} value={z.path}>
|
||||||
@@ -957,6 +1023,27 @@ export default function SettingsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
|
{!betrieb.kann.container_pfade && !!ziele.length && (
|
||||||
|
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
|
||||||
|
wählt anders — genau daran ist am 25.07. eine Platte
|
||||||
|
vollgelaufen. */
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ziele.map(z => (
|
||||||
|
<button
|
||||||
|
key={z.path}
|
||||||
|
onClick={() => handleChange('workDir', z.path)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||||
|
settings.workDir === z.path
|
||||||
|
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{z.name}{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||||||
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||||
@@ -1011,11 +1098,11 @@ export default function SettingsPage() {
|
|||||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||||
Bedienung, die nichts bedient. */
|
Bedienung, die nichts bedient. */
|
||||||
<Input
|
<OrdnerWaehler
|
||||||
label="Ordner"
|
label="Ordner"
|
||||||
value={settings.outputDir}
|
value={settings.outputDir}
|
||||||
placeholder={betrieb.ablage_vorgabe}
|
placeholder={betrieb.ablage_vorgabe}
|
||||||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
@@ -1249,6 +1336,20 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||||||
|
|
||||||
|
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||||||
|
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||||||
|
glaube das web rippy kann das." Er hatte recht:
|
||||||
|
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||||||
|
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||||||
|
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||||||
|
Eine Automatik, die man nicht sehen kann, ist für den
|
||||||
|
Benutzer keine.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
label="MakeMKV Beta-Key"
|
label="MakeMKV Beta-Key"
|
||||||
@@ -1256,6 +1357,23 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={keyHolt}
|
||||||
|
onClick={keyJetztHolen}
|
||||||
|
>
|
||||||
|
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||||||
|
</Button>
|
||||||
|
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||||||
|
+ 'der Key wechselt etwa monatlich.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||||
|
|||||||
+158
-32
@@ -47,7 +47,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from rippy import store as db
|
from rippy import store as db
|
||||||
from rippy.rip import makemkv_daten
|
from rippy.rip import makemkv_aufruf, makemkv_daten
|
||||||
import medien
|
import medien
|
||||||
from rippy.core import notify
|
from rippy.core import notify
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
|||||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
def _zielbasis(target_dir, disc_type: str, wurzeln=None) -> str:
|
||||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||||
|
|
||||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||||
@@ -167,16 +167,70 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||||
"""
|
"""
|
||||||
|
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
if target_dir:
|
if target_dir:
|
||||||
normalisiert = posixpath.normpath(target_dir)
|
normalisiert = _normalisiert(target_dir)
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
# Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
|
||||||
|
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
|
||||||
|
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
def _betriebs_wurzeln() -> tuple:
|
||||||
|
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das hier gebraucht wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch —
|
||||||
|
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
|
||||||
|
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
|
||||||
|
|
||||||
|
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
|
||||||
|
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
|
||||||
|
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
|
||||||
|
Wahl** — ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
|
||||||
|
also fiel er still auf den Container-Standard zurück.
|
||||||
|
|
||||||
|
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
|
||||||
|
bestellt hat („kannst du noch einbauen das man den arbeitsordner … setzen
|
||||||
|
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
|
||||||
|
der Worker ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
|
||||||
|
`frei` ist falsch.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisiert(wert: str) -> str:
|
||||||
|
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
|
||||||
|
|
||||||
|
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
|
||||||
|
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
|
||||||
|
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
|
||||||
|
else posixpath.normpath(wert)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
||||||
|
wurzeln=None) -> str:
|
||||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
`workDir` → Container-Default /app/temp/raw.
|
`workDir` → Vorgabe dieses Betriebs (Container: /app/temp/raw).
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
@@ -187,26 +241,48 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
|||||||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||||
denen niemand gefragt wird.
|
denen niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
|
||||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
|
||||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
|
||||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||||
wert = (kandidat or "").strip()
|
wert = (kandidat or "").strip()
|
||||||
if wert:
|
if not wert:
|
||||||
normalisiert = posixpath.normpath(wert)
|
continue
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
normalisiert = _normalisiert(wert)
|
||||||
|
# Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
|
||||||
|
# Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
|
||||||
|
# von /app/media wäre dort ein Pfad ins Nichts.
|
||||||
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return RAW_DIR
|
return vorgabe
|
||||||
|
|
||||||
|
|
||||||
def _frei_bytes(pfad: str) -> int:
|
def _frei_bytes(pfad: str) -> int:
|
||||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||||||
kandidat = pfad
|
|
||||||
|
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
An dieser Stelle stand sie als eigene Schleife:
|
||||||
|
|
||||||
while kandidat and not os.path.exists(kandidat):
|
while kandidat and not os.path.exists(kandidat):
|
||||||
kandidat = os.path.dirname(kandidat)
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
|
||||||
|
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an
|
||||||
|
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
|
||||||
|
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
|
||||||
|
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
|
||||||
|
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
|
||||||
|
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
|
||||||
|
Meldung stehen, und im Protokoll stuende nichts, womit man das
|
||||||
|
aufklären koennte.
|
||||||
|
|
||||||
|
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit
|
||||||
|
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
|
||||||
|
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
|
||||||
|
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return shutil.disk_usage(kandidat or "/").free
|
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||||||
except OSError:
|
except OSError:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -243,17 +319,27 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
|||||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
# WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
|
||||||
try:
|
# die settings.conf. Begruendung und Messung in
|
||||||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
# `rippy.rip.makemkv_aufruf.key_ablegen`.
|
||||||
alt = f.read()
|
#
|
||||||
except OSError:
|
# UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
|
||||||
alt = ""
|
# ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
|
||||||
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
# einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
|
||||||
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
# paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
|
||||||
except OSError as e:
|
# laeuft und sonst am ersten Titel scheitert.
|
||||||
|
from ripping import werkzeug
|
||||||
|
|
||||||
|
# Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start
|
||||||
|
# tut — nur in der Windows-Form.
|
||||||
|
makemkv_aufruf.einstellungen_sicherstellen()
|
||||||
|
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
|
||||||
|
if not urteil["ok"]:
|
||||||
|
db.add_log("warning", "worker",
|
||||||
|
"MakeMKV nimmt den hinterlegten Beta-Key nicht an: %s"
|
||||||
|
% urteil["grund"])
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||||
return
|
return
|
||||||
gesehen.add(text)
|
gesehen.add(text)
|
||||||
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
|
||||||
|
# sind uebersetzt, die Nummern nicht — sie sind die einzige
|
||||||
|
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
|
||||||
|
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
|
||||||
|
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
|
||||||
|
# ersatzweise an einer URL im Text festhalten.
|
||||||
|
db.add_log("info", "makemkv",
|
||||||
|
f"Job {job_id}: MSG {code} — {text[:300]}")
|
||||||
|
|
||||||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
@@ -675,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
"Normale BD/DVD gehen weiterhin."
|
"Normale BD/DVD gehen weiterhin."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
|
||||||
|
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
|
||||||
|
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
|
||||||
|
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
|
||||||
|
# eine halbe Disc für eine ganze.
|
||||||
|
if ergebnis.get("lesefehler"):
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
|
||||||
|
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
|
||||||
|
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
|
||||||
|
"von innen nach außen, nicht kreisend) oder ein anderes "
|
||||||
|
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
|
||||||
|
)
|
||||||
|
|
||||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||||
#
|
#
|
||||||
@@ -802,7 +910,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||||
final_container: str, final_lokal: str) -> str:
|
final_container: str, final_lokal: str,
|
||||||
|
fremd=None) -> str:
|
||||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||||
|
|
||||||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||||
@@ -824,7 +933,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
|||||||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||||
"""
|
"""
|
||||||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||||
fremder_worker = not os.path.isdir("/app")
|
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
|
||||||
|
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
|
||||||
|
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
|
||||||
|
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
|
||||||
|
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
|
||||||
|
#
|
||||||
|
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
|
||||||
|
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
|
||||||
|
# damit an den laufenden Rechner gebunden.
|
||||||
|
if fremd is None:
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
fremd = _extern() == "ja"
|
||||||
|
fremder_worker = fremd
|
||||||
|
|
||||||
for zweck, container, lokal in (
|
for zweck, container, lokal in (
|
||||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||||
@@ -1002,7 +1124,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"error": (
|
"error": (
|
||||||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||||
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
|
||||||
|
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
|
||||||
|
# Roh-Datei retten will, sucht sonst am falschen Ort.
|
||||||
|
f"{hb.get('error')} — Roh-Datei bleibt unter "
|
||||||
|
f"{os.path.dirname(quelle)} erhalten"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
_job_abschliessen(job_id, ergebnis)
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
|||||||
+58
-8
@@ -8,13 +8,39 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
|
||||||
|
def _extern(werte=None, container=None) -> str:
|
||||||
|
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||||
|
|
||||||
|
Drei Fälle, und nur einer davon ist „ja":
|
||||||
|
|
||||||
|
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||||
|
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||||
|
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||||
|
etwas zu übersetzen wäre.
|
||||||
|
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||||
|
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if container is None:
|
||||||
|
container = betrieb.im_container()
|
||||||
|
if container:
|
||||||
|
return "nein"
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||||
|
|
||||||
|
|
||||||
def _hb() -> str:
|
def _hb() -> str:
|
||||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||||
|
|
||||||
@@ -166,7 +192,7 @@ def hole_handbrake_presets() -> list:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--preset-list"],
|
[_hb(), "--preset-list"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, timeout=30, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -279,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
|
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER
|
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||||
)
|
)
|
||||||
return (aus.stdout or "") + (aus.stderr or "")
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -343,8 +369,18 @@ def werkzeug_versionen() -> dict:
|
|||||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||||
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||||
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
#
|
||||||
"extern": "nein" if os.path.isdir("/app") else "ja",
|
# ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
|
||||||
|
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
|
||||||
|
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
|
||||||
|
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
|
||||||
|
# Maschine, auf der es weder Container noch Freigabe gibt.
|
||||||
|
#
|
||||||
|
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
|
||||||
|
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
|
||||||
|
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
|
||||||
|
# Ordnername.
|
||||||
|
"extern": _extern(),
|
||||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||||
# grundsätzlich nicht komprimieren.
|
# grundsätzlich nicht komprimieren.
|
||||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||||
@@ -368,7 +404,7 @@ def werkzeug_versionen() -> dict:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--version"],
|
[_hb(), "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, timeout=15, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -402,7 +438,21 @@ def werkzeug_versionen() -> dict:
|
|||||||
daten_dir = makemkv_daten.DATEN_DIR
|
daten_dir = makemkv_daten.DATEN_DIR
|
||||||
except Exception:
|
except Exception:
|
||||||
daten_dir = ""
|
daten_dir = ""
|
||||||
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
# ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
|
||||||
|
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
|
||||||
|
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
|
||||||
|
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
|
||||||
|
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
|
||||||
|
#
|
||||||
|
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
|
||||||
|
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
|
||||||
|
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
|
||||||
|
from rippy import betrieb
|
||||||
|
|
||||||
|
_im_container = betrieb.im_container()
|
||||||
|
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
|
||||||
|
else os.path.isdir(daten_dir))
|
||||||
|
if werkzeuge.finden("makemkv") and daten_da:
|
||||||
try:
|
try:
|
||||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+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.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||||
|
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||||
@@ -86,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
|
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||||
|
#:
|
||||||
|
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||||
|
#: deutschsprachiges MakeMKV):
|
||||||
|
#:
|
||||||
|
#: Encountered 29 errors of type 'Read Error' - see
|
||||||
|
#: http://www.makemkv.com/errors/read/
|
||||||
|
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||||
|
#:
|
||||||
|
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||||
|
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||||
|
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||||
|
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||||
|
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||||
|
#: hierher statt dieser Textsuche.
|
||||||
|
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -102,7 +123,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
|||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
makemkv_quelle(device_path),
|
||||||
str(titel),
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
@@ -257,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
|||||||
wäre eine Minute Wartezeit für nichts.
|
wäre eine Minute Wartezeit für nichts.
|
||||||
"""
|
"""
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
ausgabe = ergebnis.stdout or ""
|
ausgabe = text_von(ergebnis.stdout)
|
||||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
alle_dateien = []
|
alle_dateien = []
|
||||||
|
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||||
|
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||||
|
lesefehler = False
|
||||||
for index, nr in enumerate(titel_liste):
|
for index, nr in enumerate(titel_liste):
|
||||||
def anteilig(p, _i=index):
|
def anteilig(p, _i=index):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -278,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
ergebnis["lesefehler"] = lesefehler
|
||||||
return ergebnis
|
return ergebnis
|
||||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||||
|
"lesefehler": lesefehler}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict, meta: dict = None):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
@@ -330,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
|||||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return {}
|
return {}
|
||||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||||
|
|
||||||
|
|
||||||
def parse_scan_dauer(ausgabe: str) -> int:
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
@@ -360,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -474,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
|||||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||||
|
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||||
|
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||||
|
".mov": "av_mov", ".webm": "av_webm"}
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||||
preset: str = DEFAULT_HB_PRESET,
|
preset: str = DEFAULT_HB_PRESET,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||||
@@ -499,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||||
|
|
||||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
@@ -508,11 +541,46 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--preset", preset,
|
||||||
]
|
]
|
||||||
|
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||||
|
# endete mit Code 0"
|
||||||
|
#
|
||||||
|
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||||
|
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||||
|
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||||
|
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||||
|
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||||
|
# nichts und nannte das „fehlgeschlagen".
|
||||||
|
#
|
||||||
|
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||||
|
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||||
|
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||||
|
# die auseinanderlaufen koennen.
|
||||||
|
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||||
|
if format_name:
|
||||||
|
befehl += ["--format", format_name]
|
||||||
audio = [s for s in (audio_sprachen or []) if s]
|
audio = [s for s in (audio_sprachen or []) if s]
|
||||||
if audio:
|
if audio:
|
||||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||||
befehl.append("--first-audio")
|
befehl.append("--first-audio")
|
||||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||||
|
#
|
||||||
|
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||||
|
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||||
|
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||||
|
#
|
||||||
|
# unknown option (--audio-codec)
|
||||||
|
# HandBrake has exited. $? = 0
|
||||||
|
#
|
||||||
|
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||||
|
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||||
|
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||||
|
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||||
|
# festschreibt, statt ihn zu finden.
|
||||||
|
#
|
||||||
|
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||||
|
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||||
if untertitel:
|
if untertitel:
|
||||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||||
@@ -577,6 +645,23 @@ def sprachliste(wert) -> list:
|
|||||||
return sauber
|
return sauber
|
||||||
|
|
||||||
|
|
||||||
|
def _datei_daneben(erwartet: str) -> str:
|
||||||
|
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||||
|
|
||||||
|
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||||
|
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||||
|
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||||
|
HandBrake ueberhaupt schreiben kann.
|
||||||
|
"""
|
||||||
|
ordner = os.path.dirname(erwartet)
|
||||||
|
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||||
|
for endung in FORMATE:
|
||||||
|
kandidat = os.path.join(ordner, stamm + endung)
|
||||||
|
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||||
|
return kandidat
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||||
progress_cb=None, abbruch_cb=None,
|
progress_cb=None, abbruch_cb=None,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||||
@@ -600,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
audio_sprachen, untertitel_sprachen),
|
audio_sprachen, untertitel_sprachen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
|
**HB_LESEN,
|
||||||
)
|
)
|
||||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -629,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
|||||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||||
|
HB_ZEILEN_PUFFER = 12
|
||||||
|
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||||
|
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||||
|
HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannter_schalter(zeile: str) -> str:
|
||||||
|
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||||
|
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||||
|
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||||
|
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||||
|
Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited.
|
||||||
|
$? = 0
|
||||||
|
|
||||||
|
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||||
|
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||||
|
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||||
|
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||||
|
|
||||||
|
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||||
|
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||||
|
statt wieder einen ganzen Rip zu kosten.
|
||||||
|
"""
|
||||||
|
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||||
|
return treffer.group(1).strip() if treffer else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hb_schluss(zeilen) -> str:
|
||||||
|
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||||
|
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||||
|
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||||
|
Ausgabe, nur hoerte niemand zu.
|
||||||
|
"""
|
||||||
|
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||||
|
if not sauber:
|
||||||
|
return ""
|
||||||
|
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
falsches_preset = ""
|
falsches_preset = ""
|
||||||
|
falscher_schalter = ""
|
||||||
|
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||||
|
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||||
|
letzte_zeilen = []
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -640,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if line.strip():
|
||||||
|
letzte_zeilen.append(line)
|
||||||
|
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||||
|
del letzte_zeilen[0]
|
||||||
if not falsches_preset:
|
if not falsches_preset:
|
||||||
falsches_preset = unbekanntes_preset(line)
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
if not falscher_schalter:
|
||||||
|
falscher_schalter = unbekannter_schalter(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -657,6 +805,57 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
|
||||||
|
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||||
|
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||||
|
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||||
|
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||||
|
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||||
|
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||||
|
if falscher_schalter:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||||
|
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||||
|
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||||
|
+ hb_schluss(letzte_zeilen)
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||||
|
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||||
|
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||||
|
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||||
|
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||||
|
#
|
||||||
|
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||||
|
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||||
|
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||||
|
if process.returncode == 0:
|
||||||
|
daneben = _datei_daneben(output_path)
|
||||||
|
if daneben:
|
||||||
|
return {"status": "success", "output_path": daneben,
|
||||||
|
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||||
|
"(der Container kommt aus dem Preset)."
|
||||||
|
% (os.path.basename(daneben),
|
||||||
|
os.path.basename(output_path))}
|
||||||
|
# Frueher stand hier geraten „Meist ist der Zielordner nicht
|
||||||
|
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
|
||||||
|
# und leer, der Grund ein falscher Schalter) — und die Vermutung
|
||||||
|
# schickte die Suche in die falsche Richtung. Jetzt wird der
|
||||||
|
# Ordner GENANNT und HandBrake selbst zitiert.
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||||
|
"entstanden (Zielordner: %s)."
|
||||||
|
% os.path.dirname(output_path)
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
|
"return_code": 0,
|
||||||
|
}
|
||||||
if falsches_preset:
|
if falsches_preset:
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -671,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
def disc_fehlt(device_path: str, zustand=None) -> str:
|
||||||
|
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
|
||||||
|
|
||||||
|
## Warum vorher nachgesehen wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander bekam:
|
||||||
|
|
||||||
|
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||||
|
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||||
|
|
||||||
|
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
|
||||||
|
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
|
||||||
|
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
|
||||||
|
und meldet einen Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
|
||||||
|
|
||||||
|
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
|
||||||
|
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
|
||||||
|
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||||
|
"""
|
||||||
|
if zustand is None:
|
||||||
|
try:
|
||||||
|
from rippy import drives as _schicht
|
||||||
|
|
||||||
|
zustand = _schicht.treiber().drive_status
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
stand = zustand(device_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
if stand == CDS_NO_DISC:
|
||||||
|
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
|
||||||
|
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
|
||||||
|
"wieder hinein." % device_path)
|
||||||
|
if stand == CDS_TRAY_OPEN:
|
||||||
|
return "Die Schublade von %s ist offen." % device_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||||
log_cb=None) -> dict:
|
log_cb=None) -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||||
@@ -723,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
|
fehlt = disc_fehlt(device_path)
|
||||||
|
if fehlt:
|
||||||
|
return {"status": "error", "error": fehlt}
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -730,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
build_makemkv_cmd(device_path, output_dir, titel),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||||
bufsize=1,
|
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
|
||||||
|
# schrieb aber UTF-8 — daraus wurde beim Commander
|
||||||
|
# „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
|
||||||
|
# nicht (Python warnt und puffert doch). Ungepuffert kommt
|
||||||
|
# jede Fortschrittszeile sofort an — bei einem Rip, der
|
||||||
|
# Stunden laeuft, ist das der Unterschied zwischen einem
|
||||||
|
# Balken und einem eingefrorenen Fenster.
|
||||||
|
bufsize=0,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||||
|
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||||
|
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||||
|
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||||
|
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||||
|
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||||
|
lesefehler = False
|
||||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
# (Befund 24.07., Summer-Wars-UHD).
|
# (Befund 24.07., Summer-Wars-UHD).
|
||||||
kritische_meldungen = []
|
kritische_meldungen = []
|
||||||
|
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
|
||||||
|
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
|
||||||
|
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
|
||||||
|
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
|
||||||
|
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
|
||||||
|
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
|
||||||
KRITISCH = (
|
KRITISCH = (
|
||||||
"volume key is unknown",
|
"volume key is unknown",
|
||||||
"evaluation period has expired",
|
"evaluation period has expired",
|
||||||
@@ -748,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"Too old version",
|
"Too old version",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for rohzeile in process.stdout:
|
||||||
|
line = text_von(rohzeile)
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0:
|
if progress >= 0:
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -758,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if meldung is None:
|
if meldung is None:
|
||||||
continue
|
continue
|
||||||
code, letzte_meldung = meldung
|
code, letzte_meldung = meldung
|
||||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
if LESEFEHLER_MARKE in letzte_meldung:
|
||||||
|
lesefehler = True
|
||||||
|
if code in KRITISCHE_CODES:
|
||||||
|
kritische_meldungen.append(
|
||||||
|
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||||
|
elif any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
kritische_meldungen.append(letzte_meldung)
|
kritische_meldungen.append(letzte_meldung)
|
||||||
if log_cb:
|
if log_cb:
|
||||||
try:
|
try:
|
||||||
@@ -786,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"files": mkv_dateien,
|
"files": mkv_dateien,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
|
"lesefehler": lesefehler,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"lesefehler": lesefehler,
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
@@ -838,10 +1114,66 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
|
||||||
|
|
||||||
|
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||||
|
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||||
|
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||||
|
|
||||||
|
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||||
|
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||||
|
"""
|
||||||
|
from rippy.drives import windows as win
|
||||||
|
from rippy.rip import audio_cd
|
||||||
|
|
||||||
|
class Leser:
|
||||||
|
toc = staticmethod(win.audio_toc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||||
|
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||||
|
fortschritt)
|
||||||
|
|
||||||
|
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||||
|
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||||
|
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||||
|
# Disc, die niemand kennt.
|
||||||
|
from rippy.rip import musicbrainz_cd
|
||||||
|
|
||||||
|
try:
|
||||||
|
spuren = win.audio_toc(device_path)
|
||||||
|
antwort = musicbrainz_cd.abfragen(
|
||||||
|
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
antwort = None
|
||||||
|
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||||
|
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||||
|
|
||||||
|
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||||
|
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||||
|
werkzeuge.finden("flac"),
|
||||||
|
fortschritt=progress_cb, tags_je_spur=tags)
|
||||||
|
if auswerfen and ergebnis.get("status") == "success":
|
||||||
|
wirf_disc_aus(device_path)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||||
auswerfen: bool = True) -> dict:
|
auswerfen: bool = True) -> dict:
|
||||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||||
Auswurf" (abcde macht das selbst per -x)."""
|
Auswurf" (abcde macht das selbst per -x)."""
|
||||||
|
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||||
|
#
|
||||||
|
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||||
|
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||||
|
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||||
|
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||||
|
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||||
|
#
|
||||||
|
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||||
|
# Wochen so im SAVEPOINT.
|
||||||
|
if os.name == "nt":
|
||||||
|
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||||
|
auswerfen)
|
||||||
|
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
|||||||
for pfad in fest:
|
for pfad in fest:
|
||||||
if os.path.isfile(pfad):
|
if os.path.isfile(pfad):
|
||||||
return pfad
|
return pfad
|
||||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||||
|
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||||
|
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||||
|
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||||
|
from rippy.tools import katalog
|
||||||
|
|
||||||
|
return (katalog.finden("makemkv")
|
||||||
|
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||||
|
|
||||||
|
|
||||||
def parse_laufwerke(ausgabe: str) -> list:
|
def parse_laufwerke(ausgabe: str) -> list:
|
||||||
|
|||||||
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
|||||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||||
|
#
|
||||||
|
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||||
|
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||||
|
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||||
|
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||||
|
# Freigabe gibt.
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_ist_nichts_extern():
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({}, container=True) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||||
|
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||||
|
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||||
|
container=False) == "ja"
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
|||||||
return tasks._erreichbarkeit_pruefen(
|
return tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||||
|
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||||
|
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||||
|
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||||
|
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||||
|
fremd=not im_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
|||||||
meldung = tasks._erreichbarkeit_pruefen(
|
meldung = tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||||
"/app/media/movies/X", "/app/media/movies/X",
|
"/app/media/movies/X", "/app/media/movies/X",
|
||||||
|
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||||
|
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||||
|
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||||
|
fremd=True,
|
||||||
)
|
)
|
||||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||||
assert "von keinem Eintrag übersetzt" in meldung
|
assert "von keinem Eintrag übersetzt" in meldung
|
||||||
|
|||||||
@@ -121,24 +121,33 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||||
|
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||||
|
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||||
|
# prompt rot geworden).
|
||||||
|
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||||
|
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
niemand gefragt wird.
|
niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
einst = {"workDir": "/app/media/movies"}
|
einst = {"workDir": "/app/media/movies"}
|
||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|||||||
|
|
||||||
|
|
||||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
w = CONTAINER
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||||
|
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||||
|
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||||
|
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||||
|
# sogar eine AUSDRUECKLICHE Wahl.
|
||||||
|
#
|
||||||
|
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||||
|
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||||
|
# ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||||
|
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||||
|
deshalb still verworfen."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||||
|
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||||
|
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||||
|
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||||
|
Wurzel."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
unc = r"\\192.168.179.62\rippy\movies"
|
||||||
|
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||||
|
assert ziel.startswith(NATIV[0])
|
||||||
|
assert "/app/" not in ziel
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
hb_schluss,
|
||||||
parse_msg,
|
parse_msg,
|
||||||
|
unbekannter_schalter,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
assert "--preset" in cmd
|
||||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||||
assert "--audio-codec" in cmd
|
assert "--aencoder" in cmd
|
||||||
assert "--all-subtitles" in cmd
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_kennt_keinen_schalter_audio_codec():
|
||||||
|
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
|
||||||
|
|
||||||
|
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
|
||||||
|
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
|
||||||
|
das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit
|
||||||
|
ins Leere, ohne dass irgendwo ein Grund stand.
|
||||||
|
|
||||||
|
⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
|
||||||
|
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
|
||||||
|
ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten
|
||||||
|
Programm gemessen, nicht aus dem Gedächtnis behauptet.
|
||||||
|
"""
|
||||||
|
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
|
||||||
|
assert "--audio-codec" not in cmd
|
||||||
|
assert cmd[cmd.index("--aencoder") + 1] == "copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_wird_erkannt():
|
||||||
|
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"unknown option (--audio-codec)") == "--audio-codec"
|
||||||
|
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
|
||||||
|
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekannter_schalter("") == ""
|
||||||
|
assert unbekannter_schalter(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
|
||||||
|
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
|
||||||
|
0 ist, sagt er nichts (Befund 30.08.2026)."""
|
||||||
|
assert hb_schluss([]) == ""
|
||||||
|
assert hb_schluss(None) == ""
|
||||||
|
assert hb_schluss([" ", " "]) == ""
|
||||||
|
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
|
||||||
|
assert "fünf" in text and "vier" in text
|
||||||
|
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
|
||||||
|
assert "eins" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
@@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|||||||
assert ergebnis["return_code"] == 3
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
|
||||||
|
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und
|
||||||
|
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
|
||||||
|
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
|
||||||
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
|
||||||
|
assert "irgendwas ganz anderes" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
|
||||||
|
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
|
||||||
|
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
|
||||||
|
|
||||||
|
Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg,
|
||||||
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
|
"""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"[13:30:54] hb_init: starting libhb thread\n",
|
||||||
|
"unknown option (--audio-codec)\n",
|
||||||
|
"HandBrake has exited.\n",
|
||||||
|
])
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
@@ -519,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
|
|||||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der
|
||||||
|
# Disk schlug fehl — keine MKV-Datei entstanden"
|
||||||
|
#
|
||||||
|
# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem
|
||||||
|
# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein
|
||||||
|
# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen
|
||||||
|
# Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_wird_vorher_erkannt():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_NO_DISC
|
||||||
|
|
||||||
|
grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC)
|
||||||
|
assert "Kein Datentraeger" in grund
|
||||||
|
assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offene_schublade_auch():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_disc_wird_nicht_gemeckert():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_DISC_OK
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT():
|
||||||
|
"""„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das
|
||||||
|
Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
def wirft(p):
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=wirft) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_makemkv_sieht_vorher_nach():
|
||||||
|
"""Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft
|
||||||
|
man weiterhin zwei Minuten ins Leere."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
quelle = inspect.getsource(ripping.run_makemkv)
|
||||||
|
vor_popen = quelle.index("subprocess.Popen")
|
||||||
|
assert "disc_fehlt(" in quelle[:vor_popen], \
|
||||||
|
"die Disc-Pruefung gehoert vor den makemkvcon-Start"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_sammelmeldung_5010_wird_erklaert():
|
||||||
|
"""5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts."""
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES
|
||||||
|
|
||||||
|
assert 5010 in KRITISCHE_CODES
|
||||||
|
assert "Disc" in KRITISCHE_CODES[5010]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
|
||||||
|
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
|
||||||
|
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
|
||||||
|
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
|
||||||
|
dist-setup*/
|
||||||
|
vendor/
|
||||||
|
.electron-cache/
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3,
|
||||||
|
// Vorlage musicbrainz_cd.py — Rechnung laut
|
||||||
|
// musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte
|
||||||
|
// AC/DC-„Back in Black"-Antwort bewiesen).
|
||||||
|
//
|
||||||
|
// DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das
|
||||||
|
// Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die
|
||||||
|
// MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann
|
||||||
|
// einfach „unbekannte Disc".
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd'
|
||||||
|
|
||||||
|
const DIENST = 'https://musicbrainz.org/ws/2/discid/'
|
||||||
|
// MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie
|
||||||
|
// wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie).
|
||||||
|
const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' }
|
||||||
|
/** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */
|
||||||
|
export const PLAETZE = 99
|
||||||
|
|
||||||
|
/** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */
|
||||||
|
export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string {
|
||||||
|
if (spurVersaetze.length === 0) return ''
|
||||||
|
const letzteSpur = letzte ?? erste + spurVersaetze.length - 1
|
||||||
|
const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0')
|
||||||
|
const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0')
|
||||||
|
const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)]
|
||||||
|
for (let platz = 0; platz < PLAETZE; platz++) {
|
||||||
|
teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0))
|
||||||
|
}
|
||||||
|
const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64')
|
||||||
|
// MusicBrainz' eigenes Alphabet: + / = werden zu . _ -
|
||||||
|
return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */
|
||||||
|
export function kennungAusSpuren(spuren: readonly Spur[]): string {
|
||||||
|
if (spuren.length === 0) return ''
|
||||||
|
const geordnet = [...spuren].sort((a, b) => a.nr - b.nr)
|
||||||
|
const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES)
|
||||||
|
const letzte = geordnet[geordnet.length - 1]
|
||||||
|
const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES
|
||||||
|
return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicBrainzAntwort {
|
||||||
|
releases?: Array<{
|
||||||
|
title?: string
|
||||||
|
date?: string
|
||||||
|
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
|
||||||
|
media?: Array<{
|
||||||
|
tracks?: Array<{
|
||||||
|
position?: number | string
|
||||||
|
title?: string
|
||||||
|
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachschlagen — null bei unbekannter Disc, keinem Netz oder Fremdem:
|
||||||
|
* alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */
|
||||||
|
export async function abfragen(
|
||||||
|
kennung: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<MusicBrainzAntwort | null> {
|
||||||
|
if (kennung.length === 0) return null
|
||||||
|
try {
|
||||||
|
const antwort = await holen(
|
||||||
|
`${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`,
|
||||||
|
{ headers: KOPF, signal: AbortSignal.timeout(15_000) },
|
||||||
|
)
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
return (await antwort.json()) as MusicBrainzAntwort
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string {
|
||||||
|
// „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " +
|
||||||
|
// „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte.
|
||||||
|
return (eintrag ?? [])
|
||||||
|
.map((s) => (s.name ?? '') + (s.joinphrase ?? ''))
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort → {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc —
|
||||||
|
* bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */
|
||||||
|
export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map<number, Record<string, string>> {
|
||||||
|
const tags = new Map<number, Record<string, string>>()
|
||||||
|
const release = antwort?.releases?.[0]
|
||||||
|
if (release === undefined) return tags
|
||||||
|
const album = release.title ?? ''
|
||||||
|
const jahr = (release.date ?? '').slice(0, 4)
|
||||||
|
const albumKuenstler = kuenstler(release['artist-credit'])
|
||||||
|
for (const medium of release.media ?? []) {
|
||||||
|
for (const spur of medium.tracks ?? []) {
|
||||||
|
const nr = Number(spur.position ?? 0)
|
||||||
|
if (!Number.isInteger(nr) || nr <= 0) continue
|
||||||
|
tags.set(nr, {
|
||||||
|
TITLE: spur.title ?? '',
|
||||||
|
ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler,
|
||||||
|
ALBUM: album,
|
||||||
|
ALBUMARTIST: albumKuenstler,
|
||||||
|
DATE: jahr,
|
||||||
|
TRACKNUMBER: String(nr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */
|
||||||
|
export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string {
|
||||||
|
const tags = tagsJeSpur(antwort)
|
||||||
|
if (tags.size === 0) return ''
|
||||||
|
const erste = tags.get(Math.min(...tags.keys()))!
|
||||||
|
const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || ''
|
||||||
|
const album = erste['ALBUM'] || ''
|
||||||
|
const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer
|
||||||
|
return saubern(roh)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// OMDb-Client — eigene Datenbasis, findet oft, was TMDb nicht exakt
|
||||||
|
// trifft. Nur Englisch; der deutsche Datensatz wird danach über
|
||||||
|
// TMDb /find (IMDb-ID) nachgeladen (zuordnung.ts). Ohne Key überspringt
|
||||||
|
// er sich selbst — kein Fehler, keine Behauptung.
|
||||||
|
|
||||||
|
export interface OmdbTreffer {
|
||||||
|
type: 'movie' | 'tv'
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
year: number | null
|
||||||
|
overview: string
|
||||||
|
poster_path: string
|
||||||
|
runtime: number
|
||||||
|
genres: string[]
|
||||||
|
source: 'omdb'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OmdbClient {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly holen: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get verfuegbar(): boolean {
|
||||||
|
return this.apiKey.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(titel: string): Promise<OmdbTreffer | null> {
|
||||||
|
if (!this.verfuegbar) return null
|
||||||
|
try {
|
||||||
|
const url = new URL('https://www.omdbapi.com/')
|
||||||
|
url.searchParams.set('apikey', this.apiKey)
|
||||||
|
url.searchParams.set('t', titel)
|
||||||
|
url.searchParams.set('r', 'json')
|
||||||
|
const antwort = await this.holen(url.toString(), { signal: AbortSignal.timeout(15_000) })
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
const daten = (await antwort.json()) as Record<string, string>
|
||||||
|
if (daten['Response'] !== 'True') return null
|
||||||
|
const jahr = /(\d{4})/.exec(daten['Year'] ?? '')
|
||||||
|
const laufzeit = /(\d+)/.exec(daten['Runtime'] ?? '')
|
||||||
|
return {
|
||||||
|
type: daten['Type'] === 'series' ? 'tv' : 'movie',
|
||||||
|
id: daten['imdbID'] ?? '',
|
||||||
|
title: daten['Title'] ?? titel,
|
||||||
|
year: jahr === null ? null : Number(jahr[1]),
|
||||||
|
overview: daten['Plot'] ?? '',
|
||||||
|
poster_path: daten['Poster'] !== 'N/A' ? (daten['Poster'] ?? '') : '',
|
||||||
|
runtime: laufzeit === null ? 0 : Number(laufzeit[1]),
|
||||||
|
genres: (daten['Genre'] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter((g) => g.length > 0),
|
||||||
|
source: 'omdb',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
|
||||||
|
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
||||||
|
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
||||||
|
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
||||||
|
|
||||||
|
export interface FilmTreffer {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
name?: string
|
||||||
|
poster_path?: string
|
||||||
|
release_date?: string
|
||||||
|
first_air_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilmDetails {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
name?: string
|
||||||
|
release_date?: string
|
||||||
|
first_air_date?: string
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
backdrop_path?: string
|
||||||
|
runtime?: number
|
||||||
|
genres?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istV4Token(key: string): boolean {
|
||||||
|
return key.startsWith('eyJ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASIS = 'https://api.themoviedb.org/3'
|
||||||
|
|
||||||
|
export class TmdbClient {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string,
|
||||||
|
private readonly holen: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get verfuegbar(): boolean {
|
||||||
|
return this.apiKey.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
|
||||||
|
if (!this.verfuegbar) return null
|
||||||
|
const url = new URL(`${BASIS}/${weg}`)
|
||||||
|
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
|
||||||
|
const kopf: Record<string, string> = { Accept: 'application/json' }
|
||||||
|
if (istV4Token(this.apiKey)) {
|
||||||
|
kopf['Authorization'] = `Bearer ${this.apiKey}`
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('api_key', this.apiKey)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const antwort = await this.holen(url.toString(), {
|
||||||
|
headers: kopf,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) return null
|
||||||
|
return await antwort.json()
|
||||||
|
} catch {
|
||||||
|
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
|
||||||
|
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
|
||||||
|
const daten = (await this.anfrage('search/movie', {
|
||||||
|
query: titel,
|
||||||
|
language: 'de-DE',
|
||||||
|
include_adult: 'false',
|
||||||
|
})) as { results?: FilmTreffer[] } | null
|
||||||
|
return daten?.results ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
|
||||||
|
const daten = (await this.anfrage('search/tv', {
|
||||||
|
query: titel,
|
||||||
|
language: 'de-DE',
|
||||||
|
include_adult: 'false',
|
||||||
|
})) as { results?: FilmTreffer[] } | null
|
||||||
|
return daten?.results ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieDetails(id: number): Promise<FilmDetails | null> {
|
||||||
|
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||||
|
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||||
|
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
|
||||||
|
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
|
||||||
|
episodes?: Array<{ episode_number: number; runtime?: number }>
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IMDb-ID → deutscher Datensatz (für OMDb-Treffer, die nur Englisch
|
||||||
|
* können — Commander-Wunsch 24.07.). */
|
||||||
|
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
|
||||||
|
const daten = (await this.anfrage(`find/${imdbId}`, {
|
||||||
|
external_source: 'imdb_id',
|
||||||
|
language: 'de-DE',
|
||||||
|
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
|
||||||
|
const film = daten?.movie_results?.[0]
|
||||||
|
if (film !== undefined) {
|
||||||
|
return {
|
||||||
|
title: film.title,
|
||||||
|
overview: film.overview,
|
||||||
|
poster_path: film.poster_path,
|
||||||
|
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
|
||||||
|
type: 'movie',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serie = daten?.tv_results?.[0]
|
||||||
|
if (serie !== undefined) {
|
||||||
|
return {
|
||||||
|
title: serie.name,
|
||||||
|
overview: serie.overview,
|
||||||
|
poster_path: serie.poster_path,
|
||||||
|
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
|
||||||
|
type: 'tv',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
|
||||||
|
// prescan.py mit ihren gemessenen Begründungen:
|
||||||
|
//
|
||||||
|
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
|
||||||
|
// 0,90 wörtlicher TMDb-Serientreffer
|
||||||
|
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
|
||||||
|
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
|
||||||
|
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
|
||||||
|
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
|
||||||
|
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
|
||||||
|
// 0,30 unbekannt
|
||||||
|
//
|
||||||
|
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||||
|
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
||||||
|
import { titelKandidaten } from './discmerkmale'
|
||||||
|
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||||
|
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||||
|
|
||||||
|
export const WENIGE_TREFFER = 3
|
||||||
|
|
||||||
|
export interface MetaTreffer {
|
||||||
|
typ: 'movie' | 'tv' | 'unknown'
|
||||||
|
id: number | string | null
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
beschreibung: string
|
||||||
|
posterPfad: string
|
||||||
|
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
|
||||||
|
backdropPfad: string
|
||||||
|
laufzeitMinuten: number
|
||||||
|
genres: string[]
|
||||||
|
quelle: 'tmdb' | 'omdb' | ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Zuordnung {
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
confidence: number
|
||||||
|
meta: MetaTreffer
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
||||||
|
return {
|
||||||
|
typ,
|
||||||
|
id,
|
||||||
|
titel: (typ === 'movie' ? details.title : details.name) ?? '',
|
||||||
|
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
|
||||||
|
beschreibung: details.overview ?? '',
|
||||||
|
posterPfad: details.poster_path ?? '',
|
||||||
|
backdropPfad: details.backdrop_path ?? '',
|
||||||
|
laufzeitMinuten: details.runtime ?? 0,
|
||||||
|
genres: (details.genres ?? []).map((g) => g.name),
|
||||||
|
quelle: 'tmdb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
||||||
|
return {
|
||||||
|
typ: treffer.type,
|
||||||
|
id: treffer.id,
|
||||||
|
titel: treffer.title,
|
||||||
|
jahr: treffer.year,
|
||||||
|
beschreibung: treffer.overview,
|
||||||
|
posterPfad: treffer.poster_path,
|
||||||
|
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
|
||||||
|
backdropPfad: '',
|
||||||
|
laufzeitMinuten: treffer.runtime,
|
||||||
|
genres: treffer.genres,
|
||||||
|
quelle: 'omdb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||||
|
titel,
|
||||||
|
jahr: null,
|
||||||
|
confidence: 0.3,
|
||||||
|
meta: {
|
||||||
|
typ: 'unknown',
|
||||||
|
id: null,
|
||||||
|
titel,
|
||||||
|
jahr: null,
|
||||||
|
beschreibung: '',
|
||||||
|
posterPfad: '',
|
||||||
|
backdropPfad: '',
|
||||||
|
laufzeitMinuten: 0,
|
||||||
|
genres: [],
|
||||||
|
quelle: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||||
|
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||||
|
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||||
|
export function wortUeberlappung(a: string, b: string): number {
|
||||||
|
const worte = (t: string): Set<string> =>
|
||||||
|
new Set(
|
||||||
|
t
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter((w) => w.length > 0),
|
||||||
|
)
|
||||||
|
const wa = worte(a)
|
||||||
|
const wb = worte(b)
|
||||||
|
if (wa.size === 0 || wb.size === 0) return 0
|
||||||
|
let gemeinsam = 0
|
||||||
|
for (const w of wa) if (wb.has(w)) gemeinsam += 1
|
||||||
|
return gemeinsam / (wa.size + wb.size - gemeinsam)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||||
|
*
|
||||||
|
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
||||||
|
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
||||||
|
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
||||||
|
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
||||||
|
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
||||||
|
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
||||||
|
export async function zuordnen(
|
||||||
|
discTitel: string,
|
||||||
|
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||||
|
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||||
|
): Promise<Zuordnung> {
|
||||||
|
const titel = discTitel.trim()
|
||||||
|
if (titel.length === 0) return UNBEKANNT('')
|
||||||
|
|
||||||
|
const kandidaten = titelKandidaten(titel)
|
||||||
|
if (kandidaten.length === 0) kandidaten.push(titel)
|
||||||
|
|
||||||
|
let ersteTreffer: FilmTreffer[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < kandidaten.length; i++) {
|
||||||
|
const kandidat = kandidaten[i]
|
||||||
|
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
||||||
|
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
||||||
|
|
||||||
|
// Wörtlicher Filmtreffer (0,95)
|
||||||
|
for (const film of filme) {
|
||||||
|
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||||
|
const details = await tmdb.movieDetails(film.id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', film.id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||||
|
// Wörtlicher Serientreffer (0,90)
|
||||||
|
for (const serie of serien) {
|
||||||
|
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||||
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
|
||||||
|
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
||||||
|
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
||||||
|
if (i === 0) {
|
||||||
|
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||||
|
const details = await tmdb.movieDetails(filme[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', filme[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
|
||||||
|
const details = await tmdb.tvDetails(serien[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('tv', serien[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
|
||||||
|
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
|
||||||
|
if (omdb.verfuegbar) {
|
||||||
|
for (const kandidat of kandidaten) {
|
||||||
|
const treffer = await omdb.lookup(kandidat)
|
||||||
|
if (treffer !== null) {
|
||||||
|
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||||
|
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||||
|
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||||
|
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
|
||||||
|
const meta = ausOmdb(treffer)
|
||||||
|
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
|
||||||
|
const deutsch = await tmdb.findByImdb(meta.id)
|
||||||
|
if (deutsch !== null) {
|
||||||
|
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
|
||||||
|
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
|
||||||
|
meta.beschreibung = deutsch['overview']
|
||||||
|
}
|
||||||
|
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
|
||||||
|
meta.posterPfad = deutsch['poster_path']
|
||||||
|
}
|
||||||
|
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
|
||||||
|
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
|
||||||
|
if (ersteTreffer.length > 0) {
|
||||||
|
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
||||||
|
if (details !== null) {
|
||||||
|
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNBEKANNT(titel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
|
||||||
|
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
|
||||||
|
return zuordnung.confidence >= schwelle
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
|
||||||
|
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
|
||||||
|
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
|
||||||
|
// liegt roh in 2352-Byte-Sektoren.
|
||||||
|
//
|
||||||
|
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
|
||||||
|
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
|
||||||
|
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
|
||||||
|
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
|
||||||
|
|
||||||
|
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
|
||||||
|
export const SEKTOR_BYTES = 2352
|
||||||
|
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
|
||||||
|
export const OFFSET_EINHEIT = 2048
|
||||||
|
export const FRAMES_JE_SEKUNDE = 75
|
||||||
|
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
|
||||||
|
export const VORLAUF_FRAMES = 150
|
||||||
|
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
|
||||||
|
export const BLOCK_SEKTOREN = 27
|
||||||
|
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
|
||||||
|
export const CDDA = 2
|
||||||
|
|
||||||
|
export interface Spur {
|
||||||
|
nr: number
|
||||||
|
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
|
||||||
|
start: number
|
||||||
|
sektoren: number
|
||||||
|
audio: boolean
|
||||||
|
dauer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSF-Adresse → logische Sektornummer (ECMA-130). Wer die 150 vergisst,
|
||||||
|
* liest jeden Track zwei Sekunden zu früh. */
|
||||||
|
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
|
||||||
|
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dauerText(sektoren: number): string {
|
||||||
|
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
|
||||||
|
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Bytes von IOCTL_CDROM_READ_TOC → Spurliste. CDROM_TOC (ntddcdrm.h):
|
||||||
|
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
|
||||||
|
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke —
|
||||||
|
* ohne ihn fehlt der letzten Spur die Länge). */
|
||||||
|
export function tocZerlegen(rohdaten: Buffer): Spur[] {
|
||||||
|
if (rohdaten.length < 12) return []
|
||||||
|
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
|
||||||
|
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
|
||||||
|
const adrControl = rohdaten[stelle + 1]
|
||||||
|
const nummer = rohdaten[stelle + 2]
|
||||||
|
const m = rohdaten[stelle + 5]
|
||||||
|
const s = rohdaten[stelle + 6]
|
||||||
|
const f = rohdaten[stelle + 7]
|
||||||
|
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
|
||||||
|
}
|
||||||
|
const spuren: Spur[] = []
|
||||||
|
for (let i = 0; i < eintraege.length; i++) {
|
||||||
|
const eintrag = eintraege[i]
|
||||||
|
if (eintrag.nr === 0xaa) continue
|
||||||
|
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
|
||||||
|
const laenge = eintraege[i + 1].lba - eintrag.lba
|
||||||
|
if (laenge <= 0) continue
|
||||||
|
spuren.push({
|
||||||
|
nr: eintrag.nr,
|
||||||
|
start: eintrag.lba,
|
||||||
|
sektoren: laenge,
|
||||||
|
audio: eintrag.audio,
|
||||||
|
dauer: dauerText(laenge),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return spuren
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Track in Lese-Blöcke zerlegen — am Stück scheitert an der
|
||||||
|
* 64-KB-Grenze vieler Treiber. */
|
||||||
|
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
|
||||||
|
if (sektoren <= 0 || jeBlock <= 0) return []
|
||||||
|
const teile: Array<[number, number]> = []
|
||||||
|
let gelesen = 0
|
||||||
|
while (gelesen < sektoren) {
|
||||||
|
const anzahl = Math.min(jeBlock, sektoren - gelesen)
|
||||||
|
teile.push([start + gelesen, anzahl])
|
||||||
|
gelesen += anzahl
|
||||||
|
}
|
||||||
|
return teile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der 44-Byte-WAV-Kopf — CD-Audio IST schon PCM im WAV-Format, es fehlt
|
||||||
|
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
|
||||||
|
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
|
||||||
|
const byteRate = (rate * kanaele * bits) / 8
|
||||||
|
const block = (kanaele * bits) / 8
|
||||||
|
const kopf = Buffer.alloc(44)
|
||||||
|
kopf.write('RIFF', 0, 'ascii')
|
||||||
|
kopf.writeUInt32LE(36 + datenBytes, 4)
|
||||||
|
kopf.write('WAVE', 8, 'ascii')
|
||||||
|
kopf.write('fmt ', 12, 'ascii')
|
||||||
|
kopf.writeUInt32LE(16, 16)
|
||||||
|
kopf.writeUInt16LE(1, 20)
|
||||||
|
kopf.writeUInt16LE(kanaele, 22)
|
||||||
|
kopf.writeUInt32LE(rate, 24)
|
||||||
|
kopf.writeUInt32LE(byteRate, 28)
|
||||||
|
kopf.writeUInt16LE(block, 32)
|
||||||
|
kopf.writeUInt16LE(bits, 34)
|
||||||
|
kopf.write('data', 36, 'ascii')
|
||||||
|
kopf.writeUInt32LE(datenBytes, 40)
|
||||||
|
return kopf
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
|
||||||
|
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
|
||||||
|
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
|
||||||
|
const eingabe = Buffer.alloc(16)
|
||||||
|
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
|
||||||
|
eingabe.writeUInt32LE(anzahl, 8)
|
||||||
|
eingabe.writeUInt32LE(CDDA, 12)
|
||||||
|
return eingabe
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS —
|
||||||
|
* „AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
|
||||||
|
export function sauberName(text: string): string {
|
||||||
|
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
|
||||||
|
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateiname(nr: number, titel = ''): string {
|
||||||
|
const sauber = sauberName(titel)
|
||||||
|
const nummer = String(nr).padStart(2, '0')
|
||||||
|
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
|
||||||
|
* den Fortschritt selbst), Tags einzeln. */
|
||||||
|
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
|
||||||
|
const befehl = ['-8', '--totally-silent', '-o', ziel]
|
||||||
|
for (const [name, wert] of Object.entries(tags)) {
|
||||||
|
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
|
||||||
|
}
|
||||||
|
befehl.push(wav)
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — die einzige echte Lücke
|
||||||
|
// gegenüber ARM: Wird eine Disc weder als Film noch als Musik erkannt,
|
||||||
|
// wird sie 1:1 gesichert statt abgelehnt. Sequenziell aus \\.\G: lesen;
|
||||||
|
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
|
||||||
|
// Bytes). Kein Fremdwerkzeug nötig.
|
||||||
|
//
|
||||||
|
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
|
||||||
|
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
|
||||||
|
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
|
||||||
|
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
|
||||||
|
// sequenziell ab 0.
|
||||||
|
import { open } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const BLOCK_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
export type IsoErgebnis =
|
||||||
|
| { status: 'success'; bytes: number }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string; bytes: number }
|
||||||
|
|
||||||
|
export async function isoSichern(
|
||||||
|
geraetepfad: string,
|
||||||
|
zielDatei: string,
|
||||||
|
erwarteteBytes: number,
|
||||||
|
aufFortschritt?: (prozent: number) => void,
|
||||||
|
abbruch?: AbortSignal,
|
||||||
|
): Promise<IsoErgebnis> {
|
||||||
|
if (erwarteteBytes <= 0) {
|
||||||
|
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
|
||||||
|
}
|
||||||
|
let quelle
|
||||||
|
try {
|
||||||
|
quelle = await open(geraetepfad, 'r')
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
let ziel
|
||||||
|
try {
|
||||||
|
ziel = await open(zielDatei, 'w')
|
||||||
|
} catch (fehler) {
|
||||||
|
await quelle.close()
|
||||||
|
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const puffer = Buffer.alloc(BLOCK_BYTES)
|
||||||
|
let gelesen = 0
|
||||||
|
let letzterProzent = -1
|
||||||
|
try {
|
||||||
|
while (gelesen < erwarteteBytes) {
|
||||||
|
if (abbruch?.aborted === true) return { status: 'cancelled' }
|
||||||
|
const rest = erwarteteBytes - gelesen
|
||||||
|
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
|
||||||
|
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
|
||||||
|
if (bytesRead <= 0) break
|
||||||
|
await ziel.write(puffer, 0, bytesRead)
|
||||||
|
gelesen += bytesRead
|
||||||
|
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
|
||||||
|
if (prozent !== letzterProzent) {
|
||||||
|
letzterProzent = prozent
|
||||||
|
aufFortschritt?.(prozent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
|
||||||
|
} finally {
|
||||||
|
await quelle.close()
|
||||||
|
await ziel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gelesen !== erwarteteBytes) {
|
||||||
|
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
|
||||||
|
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
|
||||||
|
bytes: gelesen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: 'success', bytes: gelesen }
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
|
||||||
|
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
|
||||||
|
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
|
||||||
|
//
|
||||||
|
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
|
||||||
|
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
|
||||||
|
// hing bzw. stürzte — Befund 23.07.)
|
||||||
|
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
|
||||||
|
// Kodierungen; „Das Öffnen" war die Folge von text=True.
|
||||||
|
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
|
||||||
|
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
||||||
|
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
||||||
|
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { mkdirSync, readdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
KRITISCHE_CODES,
|
||||||
|
KRITISCHE_TEXTE,
|
||||||
|
LESEFEHLER_MARKE,
|
||||||
|
parseMsg,
|
||||||
|
parseStreamInfo,
|
||||||
|
parseTitelInfo,
|
||||||
|
progressAusPrgv,
|
||||||
|
quelle,
|
||||||
|
sprachenZusammenfassen,
|
||||||
|
textVon,
|
||||||
|
type SprachEintrag,
|
||||||
|
type TitelInfo,
|
||||||
|
type TitelStreams,
|
||||||
|
} from './parser'
|
||||||
|
|
||||||
|
export function buildInfoArgs(geraetepfad: string): string[] {
|
||||||
|
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
||||||
|
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||||
|
export class RipAuswertung {
|
||||||
|
prozent = -1
|
||||||
|
letzteMeldung = ''
|
||||||
|
lesefehler = false
|
||||||
|
kritischeMeldungen: string[] = []
|
||||||
|
|
||||||
|
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||||
|
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||||
|
const p = progressAusPrgv(zeile)
|
||||||
|
if (p >= 0) {
|
||||||
|
if (p === this.prozent) return {}
|
||||||
|
this.prozent = p
|
||||||
|
return { prozent: p }
|
||||||
|
}
|
||||||
|
const meldung = parseMsg(zeile)
|
||||||
|
if (meldung === null) return {}
|
||||||
|
this.letzteMeldung = meldung.text
|
||||||
|
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||||
|
if (meldung.code in KRITISCHE_CODES) {
|
||||||
|
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||||
|
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||||
|
this.kritischeMeldungen.push(meldung.text)
|
||||||
|
}
|
||||||
|
return { meldung }
|
||||||
|
}
|
||||||
|
|
||||||
|
fehlerText(returncode: number | null, dateienDa: boolean): string {
|
||||||
|
return (
|
||||||
|
`makemkvcon endete mit Code ${returncode ?? '?'}` +
|
||||||
|
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
|
||||||
|
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
|
||||||
|
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
|
||||||
|
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
|
||||||
|
export class ZeilenLeser {
|
||||||
|
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 (;;) {
|
||||||
|
const umbruch = this.rest.indexOf(0x0a)
|
||||||
|
if (umbruch < 0) break
|
||||||
|
let ende = umbruch
|
||||||
|
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
|
||||||
|
zeilen.push(textVon(this.rest.subarray(0, ende)))
|
||||||
|
this.rest = this.rest.subarray(umbruch + 1)
|
||||||
|
}
|
||||||
|
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 interface DiscInfo {
|
||||||
|
titel: TitelInfo[]
|
||||||
|
streams: Map<number, TitelStreams>
|
||||||
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
||||||
|
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
|
||||||
|
export function titelInfoLesen(
|
||||||
|
programm: string,
|
||||||
|
geraetepfad: string,
|
||||||
|
timeoutMs = 300_000,
|
||||||
|
): Promise<DiscInfo> {
|
||||||
|
return new Promise((fertig, scheitert) => {
|
||||||
|
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
|
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
|
const zeiger = setTimeout(() => {
|
||||||
|
kind.kill()
|
||||||
|
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||||
|
}, timeoutMs)
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
scheitert(fehler)
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||||
|
const titel = parseTitelInfo(ausgabe)
|
||||||
|
const streams = parseStreamInfo(ausgabe)
|
||||||
|
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RipErgebnis =
|
||||||
|
| { status: 'success'; dateien: string[]; lesefehler: boolean }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string; lesefehler: boolean }
|
||||||
|
|
||||||
|
export interface RipLaufOptionen {
|
||||||
|
programm: string
|
||||||
|
geraetepfad: string
|
||||||
|
zielOrdner: string
|
||||||
|
/** 'all' oder eine Titel-Nummer als Text. */
|
||||||
|
titel: string
|
||||||
|
aufFortschritt?: (prozent: number) => void
|
||||||
|
aufMeldung?: (code: number, text: string) => void
|
||||||
|
abbruch?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
|
||||||
|
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
|
||||||
|
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
mkdirSync(optionen.zielOrdner, { recursive: true })
|
||||||
|
|
||||||
|
const kind = spawn(
|
||||||
|
optionen.programm,
|
||||||
|
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
||||||
|
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const auswertung = new RipAuswertung()
|
||||||
|
const leser = new ZeilenLeser()
|
||||||
|
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)
|
||||||
|
if (ergebnis.meldung !== undefined) {
|
||||||
|
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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), lesefehler: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
kind.on('close', (code) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
leser.schluss().forEach(zeile)
|
||||||
|
if (abgebrochen) {
|
||||||
|
fertig({ status: 'cancelled' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dateien: string[] = []
|
||||||
|
try {
|
||||||
|
dateien = readdirSync(optionen.zielOrdner)
|
||||||
|
.filter((name) => name.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
|
.map((name) => join(optionen.zielOrdner, name))
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code === 0 && dateien.length > 0) {
|
||||||
|
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({
|
||||||
|
status: 'error',
|
||||||
|
fehler: auswertung.fehlerText(code, dateien.length > 0),
|
||||||
|
lesefehler: auswertung.lesefehler,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// makemkvcon-Ausgabe lesen — reine Textverarbeitung, vollständig testbar
|
||||||
|
// ohne Laufwerk (KONZEPT § 6.2). Format laut
|
||||||
|
// https://www.makemkv.com/developers/usage.txt (Robot-Mode -r); jede
|
||||||
|
// Eigenheit hier ist im Docker-Zweig an echten Discs GEMESSEN worden
|
||||||
|
// (ripping.py / makemkv_aufruf.py) und wandert mit ihren Testfällen mit.
|
||||||
|
|
||||||
|
// ── Quelle: Wie MakeMKV DIESES Laufwerk nennt ───────────────────────────
|
||||||
|
// Der 29.08.-Befund: `dev:\\.\G:` → MSG 2024 „Unknown device";
|
||||||
|
// `dev:G:` → 68 TINFO-Zeilen. CreateFileW braucht den Geräte-Namensraum,
|
||||||
|
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
|
||||||
|
// Pfades, nicht nach dem Rechner.
|
||||||
|
export function quelle(geraetepfad: string): string {
|
||||||
|
let pfad = (geraetepfad ?? '').trim()
|
||||||
|
if (pfad.length === 0) return ''
|
||||||
|
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
|
||||||
|
if (pfad.startsWith(vorsatz)) {
|
||||||
|
pfad = pfad.slice(vorsatz.length)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// „G:\" → „G:" — ohne Trenner ist es das, was MakeMKV selbst ausgibt.
|
||||||
|
if (pfad.length === 3 && pfad[1] === ':' && (pfad[2] === '\\' || pfad[2] === '/')) {
|
||||||
|
pfad = pfad.slice(0, 2)
|
||||||
|
}
|
||||||
|
return `dev:${pfad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kodierung: BINÄR lesen, dann bestimmen statt behaupten ──────────────
|
||||||
|
// makemkvcon mischt Kodierungen je nach Konsole (UTF-8 oder cp1252): Beim
|
||||||
|
// Commander wurde aus „Öffnen" ein „Öffnen". UTF-8 STRENG versuchen,
|
||||||
|
// sonst cp1252 — mit errors=replace ginge jeder Umlaut still unter.
|
||||||
|
export function textVon(roh: Buffer | string): string {
|
||||||
|
if (typeof roh === 'string') return roh
|
||||||
|
if (roh.length === 0) return ''
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(roh)
|
||||||
|
} catch {
|
||||||
|
// cp1252 kann jedes Byte abbilden — hier kann nichts mehr scheitern.
|
||||||
|
return new TextDecoder('windows-1252').decode(roh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MSG-Zeilen ──────────────────────────────────────────────────────────
|
||||||
|
// MSG:code,flags,count,"message",… — Feld 4 ist der fertige Klartext.
|
||||||
|
// Ein naives split(",") schnitt jede Meldung mit Komma ab (25.07.2026) —
|
||||||
|
// und MakeMKV schreibt solche laufend.
|
||||||
|
const MSG_ZEILE = /^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"/
|
||||||
|
|
||||||
|
export function parseMsg(zeile: string): { code: number; text: string } | null {
|
||||||
|
const treffer = MSG_ZEILE.exec(zeile.trim())
|
||||||
|
if (treffer === null) return null
|
||||||
|
return { code: Number(treffer[1]), text: treffer[2].replaceAll('\\"', '"') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PRGV: Gesamtfortschritt ─────────────────────────────────────────────
|
||||||
|
// PRGV:current,total,max — total/max ist der Gesamtfortschritt.
|
||||||
|
// -1 heißt „keine Angabe": Ein echtes 0 % muss von „nichts" unterscheidbar
|
||||||
|
// bleiben, sonst friert der Anfang jedes Rips optisch ein.
|
||||||
|
export function progressAusPrgv(zeile: string): number {
|
||||||
|
const treffer = /^PRGV:(\d+),(\d+),(\d+)/.exec(zeile.trim())
|
||||||
|
if (treffer === null) return -1
|
||||||
|
const gesamt = Number(treffer[2])
|
||||||
|
const maximum = Number(treffer[3])
|
||||||
|
if (maximum <= 0) return -1
|
||||||
|
return Math.min(100, Math.floor((gesamt * 100) / maximum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
|
||||||
|
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
|
||||||
|
// ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||||
|
export interface TitelInfo {
|
||||||
|
nr: number
|
||||||
|
dauerS: number
|
||||||
|
groesseBytes: number
|
||||||
|
kapitel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TINFO_ZEILE = /^TINFO:(\d+),(\d+),\d+,"(.*)"\s*$/
|
||||||
|
|
||||||
|
export function parseTitelInfo(ausgabe: string): TitelInfo[] {
|
||||||
|
const titel = new Map<number, TitelInfo>()
|
||||||
|
for (const roh of ausgabe.split(/\r?\n/)) {
|
||||||
|
const treffer = TINFO_ZEILE.exec(roh.trim())
|
||||||
|
if (treffer === null) continue
|
||||||
|
const nr = Number(treffer[1])
|
||||||
|
const attr = Number(treffer[2])
|
||||||
|
const wert = treffer[3]
|
||||||
|
let eintrag = titel.get(nr)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 }
|
||||||
|
titel.set(nr, eintrag)
|
||||||
|
}
|
||||||
|
if (attr === 9) {
|
||||||
|
const teile = wert.split(':')
|
||||||
|
if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) {
|
||||||
|
eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2])
|
||||||
|
}
|
||||||
|
} else if (attr === 11 && /^\d+$/.test(wert)) {
|
||||||
|
eintrag.groesseBytes = Number(wert)
|
||||||
|
} else if (attr === 8 && /^\d+$/.test(wert)) {
|
||||||
|
eintrag.kapitel = Number(wert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SINFO: Tonspuren und Untertitel ─────────────────────────────────────
|
||||||
|
// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen:
|
||||||
|
// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname,
|
||||||
|
// 6 = Codec, 14 = Kanäle, 30 = Beschreibung.
|
||||||
|
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
|
||||||
|
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
|
||||||
|
// jede Disc für englisch.
|
||||||
|
export interface StreamInfo {
|
||||||
|
nr: number
|
||||||
|
typ: string
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
codec: string
|
||||||
|
kanaele: string
|
||||||
|
beschreibung: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitelStreams {
|
||||||
|
audio: StreamInfo[]
|
||||||
|
untertitel: StreamInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
|
||||||
|
|
||||||
|
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
||||||
|
const titel = new Map<number, Map<number, StreamInfo>>()
|
||||||
|
for (const roh of (ausgabe ?? '').split(/\r?\n/)) {
|
||||||
|
const treffer = SINFO_ZEILE.exec(roh.trim())
|
||||||
|
if (treffer === null) continue
|
||||||
|
const tNr = Number(treffer[1])
|
||||||
|
const sNr = Number(treffer[2])
|
||||||
|
const attr = Number(treffer[3])
|
||||||
|
const wert = treffer[4]
|
||||||
|
let streams = titel.get(tNr)
|
||||||
|
if (streams === undefined) {
|
||||||
|
streams = new Map()
|
||||||
|
titel.set(tNr, streams)
|
||||||
|
}
|
||||||
|
let eintrag = streams.get(sNr)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
|
||||||
|
streams.set(sNr, eintrag)
|
||||||
|
}
|
||||||
|
if (attr === 1) eintrag.typ = wert
|
||||||
|
else if (attr === 3) eintrag.lang = wert
|
||||||
|
else if (attr === 4) eintrag.sprache = wert
|
||||||
|
else if (attr === 6) eintrag.codec = wert
|
||||||
|
else if (attr === 14) eintrag.kanaele = wert
|
||||||
|
else if (attr === 30) eintrag.beschreibung = wert
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = new Map<number, TitelStreams>()
|
||||||
|
for (const [tNr, streams] of titel) {
|
||||||
|
const audio: StreamInfo[] = []
|
||||||
|
const untertitel: StreamInfo[] = []
|
||||||
|
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
|
||||||
|
const s = streams.get(sNr)!
|
||||||
|
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
|
||||||
|
if (s.typ === 'Audio') audio.push(s)
|
||||||
|
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
|
||||||
|
}
|
||||||
|
ergebnis.set(tNr, { audio, untertitel })
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SprachEintrag {
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
spuren: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||||
|
* Reihenfolge stabil (wie auf der Disc). */
|
||||||
|
export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>): {
|
||||||
|
audio: SprachEintrag[]
|
||||||
|
untertitel: SprachEintrag[]
|
||||||
|
} {
|
||||||
|
const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => {
|
||||||
|
const gefunden = new Map<string, SprachEintrag>()
|
||||||
|
for (const streams of streamsJeTitel.values()) {
|
||||||
|
for (const s of streams[schluessel]) {
|
||||||
|
if (s.lang.length === 0) continue
|
||||||
|
let eintrag = gefunden.get(s.lang)
|
||||||
|
if (eintrag === undefined) {
|
||||||
|
eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 }
|
||||||
|
gefunden.set(s.lang, eintrag)
|
||||||
|
}
|
||||||
|
eintrag.spuren += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...gefunden.values()]
|
||||||
|
}
|
||||||
|
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
|
||||||
|
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
|
||||||
|
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
|
||||||
|
* vermeidet „Play All"-Titel, die länger als der Film sind. */
|
||||||
|
export function laengsterTitel(dauern: Map<number, number>, sollMinuten = 0): number | null {
|
||||||
|
if (dauern.size === 0) return null
|
||||||
|
const zielS = sollMinuten * 60
|
||||||
|
if (zielS > 0) {
|
||||||
|
let bester: number | null = null
|
||||||
|
let abstand = Number.POSITIVE_INFINITY
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
const d = Math.abs(dauer - zielS)
|
||||||
|
if (d < abstand) {
|
||||||
|
abstand = d
|
||||||
|
bester = nr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bester !== null && abstand <= 20 * 60) return bester
|
||||||
|
}
|
||||||
|
let laengster: number | null = null
|
||||||
|
let max = -1
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
if (dauer > max) {
|
||||||
|
max = dauer
|
||||||
|
laengster = nr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laengster
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der
|
||||||
|
* bekannten Episoden-Laufzeiten (Minuten) liegt. */
|
||||||
|
export function episodenTitel(dauern: Map<number, number>, episodenMinuten: number[]): number[] {
|
||||||
|
if (dauern.size === 0 || episodenMinuten.length === 0) return []
|
||||||
|
const laufzeitenS = episodenMinuten.map((m) => m * 60)
|
||||||
|
const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length
|
||||||
|
const gefunden: number[] = []
|
||||||
|
for (const [nr, dauer] of dauern) {
|
||||||
|
if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr)
|
||||||
|
}
|
||||||
|
return gefunden.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fehler-Erkennung ────────────────────────────────────────────────────
|
||||||
|
// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht
|
||||||
|
// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da
|
||||||
|
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||||
|
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
||||||
|
|
||||||
|
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||||
|
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||||
|
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
||||||
|
export const KRITISCHE_CODES: Record<number, string> = {
|
||||||
|
2024: 'MakeMKV kennt dieses Laufwerk nicht',
|
||||||
|
5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus',
|
||||||
|
5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen',
|
||||||
|
5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren',
|
||||||
|
// Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt
|
||||||
|
// gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere
|
||||||
|
// DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2,
|
||||||
|
// GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt,
|
||||||
|
// und diese Ebene blieb hängen.
|
||||||
|
5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten',
|
||||||
|
5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */
|
||||||
|
export const KEY_ABGELEHNT = [5020, 5021]
|
||||||
|
|
||||||
|
// Text-Rückfall für englische Installationen — die NUMMERN sind die
|
||||||
|
// verlässliche Quelle (beim Commander meldet MakeMKV deutsch).
|
||||||
|
export const KRITISCHE_TEXTE = [
|
||||||
|
'volume key is unknown',
|
||||||
|
'evaluation period has expired',
|
||||||
|
'registration key',
|
||||||
|
'Too old version',
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
|
||||||
|
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
|
||||||
|
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
|
||||||
|
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
|
||||||
|
// einer Konfigurationsdatei, die niemand schrieb.
|
||||||
|
//
|
||||||
|
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
|
||||||
|
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
|
||||||
|
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
export class Datenbank {
|
||||||
|
readonly pfad: string
|
||||||
|
private db: DatabaseSync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet (oder erzeugt) die Datenbank und legt das Schema an.
|
||||||
|
* Wirft bei nicht anlegbarem Pfad — der Aufrufer meldet den Fehler
|
||||||
|
* LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten.
|
||||||
|
*/
|
||||||
|
constructor(pfad: string) {
|
||||||
|
this.pfad = pfad
|
||||||
|
if (pfad !== ':memory:') {
|
||||||
|
mkdirSync(dirname(pfad), { recursive: true })
|
||||||
|
}
|
||||||
|
this.db = new DatabaseSync(pfad)
|
||||||
|
this.db.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS bibliothek (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
jahr INTEGER,
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
groesse_bytes INTEGER NOT NULL,
|
||||||
|
ablageort TEXT NOT NULL,
|
||||||
|
datum TEXT NOT NULL,
|
||||||
|
dauer_s INTEGER NOT NULL,
|
||||||
|
poster_pfad TEXT NOT NULL DEFAULT ''
|
||||||
|
);`,
|
||||||
|
)
|
||||||
|
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
||||||
|
// Poster-Spalte — nachrüsten, ohne Einträge anzufassen.
|
||||||
|
const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }>
|
||||||
|
if (!spalten.some((s) => s.name === 'poster_pfad')) {
|
||||||
|
this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
this.db
|
||||||
|
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
|
||||||
|
.run(String(SCHEMA_VERSION))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */
|
||||||
|
einstellungLesen(schluessel: string): string | null {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`)
|
||||||
|
.get(schluessel) as { wert: string } | undefined
|
||||||
|
return zeile === undefined ? null : zeile.wert
|
||||||
|
}
|
||||||
|
|
||||||
|
einstellungSchreiben(schluessel: string, wert: string): void {
|
||||||
|
this.db
|
||||||
|
.prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`)
|
||||||
|
.run(schluessel, wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaVersion(): number {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`)
|
||||||
|
.get() as { wert: string } | undefined
|
||||||
|
return zeile === undefined ? 0 : Number(zeile.wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am
|
||||||
|
// Disc-Fingerabdruck ────────────────────────────────────────────────
|
||||||
|
bibliothekEintragen(eintrag: BibliothekEintrag): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO bibliothek
|
||||||
|
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
eintrag.fingerprint,
|
||||||
|
eintrag.titel,
|
||||||
|
eintrag.jahr,
|
||||||
|
eintrag.typ,
|
||||||
|
eintrag.groesseBytes,
|
||||||
|
eintrag.ablageort,
|
||||||
|
eintrag.datum,
|
||||||
|
eintrag.dauerS,
|
||||||
|
eintrag.posterPfad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
bibliothekListe(): BibliothekEintrag[] {
|
||||||
|
const zeilen = this.db
|
||||||
|
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
|
||||||
|
.all() as Array<Record<string, unknown>>
|
||||||
|
return zeilen.map((z) => ({
|
||||||
|
fingerprint: String(z.fingerprint),
|
||||||
|
titel: String(z.titel),
|
||||||
|
jahr: z.jahr === null ? null : Number(z.jahr),
|
||||||
|
typ: String(z.typ),
|
||||||
|
groesseBytes: Number(z.groesse_bytes),
|
||||||
|
ablageort: String(z.ablageort),
|
||||||
|
datum: String(z.datum),
|
||||||
|
dauerS: Number(z.dauer_s),
|
||||||
|
posterPfad: String(z.poster_pfad ?? ''),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum des früheren Rips dieser Disc — oder null (neu). */
|
||||||
|
bibliothekSchonGerippt(fingerprint: string): string | null {
|
||||||
|
const zeile = this.db
|
||||||
|
.prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`)
|
||||||
|
.get(fingerprint) as { datum: string } | undefined
|
||||||
|
return zeile === undefined ? null : zeile.datum
|
||||||
|
}
|
||||||
|
|
||||||
|
schliessen(): void {
|
||||||
|
this.db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage
|
||||||
|
// tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am
|
||||||
|
// 31.08.2026 nachgemessen):
|
||||||
|
//
|
||||||
|
// Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht)
|
||||||
|
// Hersteller-Forum HTTP 200, 0,2 s → 1.18.4
|
||||||
|
// Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2
|
||||||
|
//
|
||||||
|
// Daraus folgen die drei Regeln dieser Datei:
|
||||||
|
// 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich.
|
||||||
|
// Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die
|
||||||
|
// HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus,
|
||||||
|
// würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste
|
||||||
|
// Fassung" verkauft.
|
||||||
|
// 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht
|
||||||
|
// (Get-AuthenticodeSignature → NotSigned, an der echten Datei
|
||||||
|
// gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft
|
||||||
|
// inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026
|
||||||
|
// an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!).
|
||||||
|
// Das ersetzt keine Signatur, fängt aber ab, was wirklich droht:
|
||||||
|
// Fehlerseite, halber Download, falsche Version.
|
||||||
|
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
||||||
|
// `.neu`, geprüft, erst dann getauscht.
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
import { werkzeugOrdner } from './katalog'
|
||||||
|
|
||||||
|
export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/'
|
||||||
|
export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3'
|
||||||
|
// Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das
|
||||||
|
// Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py).
|
||||||
|
const ARCHIV_STAND = '2025'
|
||||||
|
const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe'
|
||||||
|
|
||||||
|
const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g
|
||||||
|
|
||||||
|
// Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und
|
||||||
|
// begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine
|
||||||
|
// Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin.
|
||||||
|
const VERSIONS_TIMEOUT_MS = 8_000
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 600_000
|
||||||
|
const VERSUCHE = 2
|
||||||
|
const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000
|
||||||
|
|
||||||
|
// Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern
|
||||||
|
// eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde.
|
||||||
|
const MINDESTGROESSE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
const MAKEMKV_FIRMA = 'GuinpinSoft'
|
||||||
|
const MAKEMKV_PRODUKT = 'MakeMKV'
|
||||||
|
|
||||||
|
export class BeschaffungsFehler extends Error {}
|
||||||
|
|
||||||
|
/** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH
|
||||||
|
* verglichen — als Zeichenkette wäre „1.9.2" größer als „1.18.4". */
|
||||||
|
export function hoechsteVersion(text: string): string {
|
||||||
|
const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1])
|
||||||
|
if (funde.length === 0) return ''
|
||||||
|
return funde.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */
|
||||||
|
export function istNeuer(neu: string, alt: string): boolean {
|
||||||
|
const teile = (v: string): number[] =>
|
||||||
|
(v ?? '')
|
||||||
|
.split('.')
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map(Number)
|
||||||
|
const a = teile(neu)
|
||||||
|
const b = teile(alt)
|
||||||
|
if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
const x = a[i] ?? 0
|
||||||
|
const y = b[i] ?? 0
|
||||||
|
if (x !== y) return x > y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionsQuelle {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
/** Diese Quelle IST die Wahrheit — wer sie erreicht, sucht nicht weiter.
|
||||||
|
* Gilt nur für den Hersteller selbst. */
|
||||||
|
massgeblich: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionsquellen(): VersionsQuelle[] {
|
||||||
|
return [
|
||||||
|
{ name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true },
|
||||||
|
{ name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false },
|
||||||
|
{
|
||||||
|
name: 'Archiv der Hersteller-Seite',
|
||||||
|
url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`,
|
||||||
|
massgeblich: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seiteLaden(url: string, holen: typeof fetch): Promise<string> {
|
||||||
|
let letzter: unknown = null
|
||||||
|
for (let versuch = 0; versuch < VERSUCHE; versuch++) {
|
||||||
|
try {
|
||||||
|
const antwort = await holen(url, {
|
||||||
|
signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`)
|
||||||
|
return await antwort.text()
|
||||||
|
} catch (fehler) {
|
||||||
|
letzter = fehler
|
||||||
|
if (versuch + 1 < VERSUCHE) {
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw letzter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */
|
||||||
|
export async function makemkvNeuesteVersion(
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
quellen: VersionsQuelle[] = versionsquellen(),
|
||||||
|
): Promise<string> {
|
||||||
|
const gefunden: string[] = []
|
||||||
|
for (const quelle of quellen) {
|
||||||
|
let text: string
|
||||||
|
try {
|
||||||
|
text = await seiteLaden(quelle.url, holen)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const version = hoechsteVersion(text)
|
||||||
|
if (version.length === 0) continue
|
||||||
|
if (quelle.massgeblich) return version
|
||||||
|
gefunden.push(version)
|
||||||
|
}
|
||||||
|
if (gefunden.length === 0) return ''
|
||||||
|
return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis
|
||||||
|
* (wer sie einträgt, hat sich etwas gedacht) → Hersteller → Archiv. */
|
||||||
|
export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> {
|
||||||
|
const herstellerUrl = MAKEMKV_DATEI.replace('%s', version)
|
||||||
|
const quellen: Array<[string, string]> = []
|
||||||
|
if (basis.trim().length > 0) {
|
||||||
|
quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`])
|
||||||
|
}
|
||||||
|
quellen.push(['Hersteller', herstellerUrl])
|
||||||
|
// `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen).
|
||||||
|
quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`])
|
||||||
|
return quellen
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Versionsangaben {
|
||||||
|
firma: string
|
||||||
|
produkt: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Versions-Ressource einer EXE — über PowerShells FileVersionInfo,
|
||||||
|
* damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */
|
||||||
|
export function versionsangaben(dateiPfad: string): Versionsangaben {
|
||||||
|
const leer = { firma: '', produkt: '', version: '' }
|
||||||
|
if (process.platform !== 'win32') return leer
|
||||||
|
// ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein
|
||||||
|
// wörtliches '…'-Literal nicht.
|
||||||
|
const skript =
|
||||||
|
`$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` +
|
||||||
|
'@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress'
|
||||||
|
const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], {
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 20_000,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (lauf.status !== 0) return leer
|
||||||
|
try {
|
||||||
|
const roh: unknown = JSON.parse(lauf.stdout ?? '')
|
||||||
|
if (typeof roh !== 'object' || roh === null) return leer
|
||||||
|
const angaben = roh as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '',
|
||||||
|
produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '',
|
||||||
|
version: typeof angaben['version'] === 'string' ? angaben['version'] : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return leer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */
|
||||||
|
export function dateiPruefen(
|
||||||
|
dateiPfad: string,
|
||||||
|
version: string,
|
||||||
|
angaben: (p: string) => Versionsangaben = versionsangaben,
|
||||||
|
plattform: NodeJS.Platform = process.platform,
|
||||||
|
): { ok: boolean; grund: string } {
|
||||||
|
// Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte
|
||||||
|
// Fehlerseite tut das nicht.
|
||||||
|
const kopf = Buffer.alloc(2)
|
||||||
|
try {
|
||||||
|
const datei = openSync(dateiPfad, 'r')
|
||||||
|
try {
|
||||||
|
readSync(datei, kopf, 0, 2, 0)
|
||||||
|
} finally {
|
||||||
|
closeSync(datei)
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` }
|
||||||
|
}
|
||||||
|
if (kopf.toString('latin1') !== 'MZ') {
|
||||||
|
return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' }
|
||||||
|
}
|
||||||
|
if (plattform !== 'win32') {
|
||||||
|
// Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen,
|
||||||
|
// mehr ist dort nicht zu holen (CI läuft auf Linux).
|
||||||
|
return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' }
|
||||||
|
}
|
||||||
|
const info = angaben(dateiPfad)
|
||||||
|
if (!info.firma.includes(MAKEMKV_FIRMA)) {
|
||||||
|
return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` }
|
||||||
|
}
|
||||||
|
if (!info.produkt.includes(MAKEMKV_PRODUKT)) {
|
||||||
|
return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` }
|
||||||
|
}
|
||||||
|
if (version.length > 0 && !info.version.includes(version)) {
|
||||||
|
return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` }
|
||||||
|
}
|
||||||
|
return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateiLaden(
|
||||||
|
url: string,
|
||||||
|
ziel: string,
|
||||||
|
melden: (text: string, anteil: number | null) => void,
|
||||||
|
holen: typeof fetch,
|
||||||
|
): Promise<void> {
|
||||||
|
const antwort = await holen(url, {
|
||||||
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`)
|
||||||
|
if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt')
|
||||||
|
const erwartet = Number(antwort.headers.get('content-length') ?? '0')
|
||||||
|
const schreiber = createWriteStream(ziel)
|
||||||
|
const leser = antwort.body.getReader()
|
||||||
|
let geladen = 0
|
||||||
|
let letzteStufe = -1
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await leser.read()
|
||||||
|
if (done) break
|
||||||
|
geladen += value.byteLength
|
||||||
|
await new Promise<void>((weiter, kaputt) => {
|
||||||
|
schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter()))
|
||||||
|
})
|
||||||
|
if (erwartet > 0) {
|
||||||
|
const anteil = Math.min(1, geladen / erwartet)
|
||||||
|
// Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen
|
||||||
|
// wären sonst hundert Zeilen.
|
||||||
|
const stufe = Math.floor(anteil * 10)
|
||||||
|
if (stufe !== letzteStufe) {
|
||||||
|
letzteStufe = stufe
|
||||||
|
melden(
|
||||||
|
`${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`,
|
||||||
|
anteil * 0.85,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((weiter) => schreiber.end(weiter))
|
||||||
|
}
|
||||||
|
const groesse = statSync(ziel).size
|
||||||
|
if (groesse < MINDESTGROESSE_BYTES) {
|
||||||
|
throw new BeschaffungsFehler(
|
||||||
|
`Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeschaffungErgebnis {
|
||||||
|
pfad: string
|
||||||
|
quelle: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
||||||
|
* Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** — es lädt die
|
||||||
|
* offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */
|
||||||
|
export async function makemkvInstallerHolen(
|
||||||
|
version: string,
|
||||||
|
melden: (text: string, anteil: number | null) => void,
|
||||||
|
basis = '',
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
||||||
|
pruefen: typeof dateiPruefen = dateiPruefen,
|
||||||
|
): Promise<BeschaffungErgebnis> {
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
||||||
|
const vorlaeufig = `${ziel}.neu`
|
||||||
|
|
||||||
|
const fehlschlaege: string[] = []
|
||||||
|
for (const [name, url] of makemkvQuellen(version, basis)) {
|
||||||
|
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
||||||
|
try {
|
||||||
|
await dateiLaden(url, vorlaeufig, melden, holen)
|
||||||
|
const pruefung = pruefen(vorlaeufig, version)
|
||||||
|
if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund)
|
||||||
|
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||||||
|
// Datei nicht ersetzen.
|
||||||
|
renameSync(vorlaeufig, ziel)
|
||||||
|
return { pfad: ziel, quelle: name, version }
|
||||||
|
} catch (fehler) {
|
||||||
|
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
||||||
|
melden(`${name} hat nicht geliefert.`, null)
|
||||||
|
try {
|
||||||
|
rmSync(vorlaeufig, { force: true })
|
||||||
|
} catch {
|
||||||
|
// Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BeschaffungsFehler(
|
||||||
|
`Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` +
|
||||||
|
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9).
|
||||||
|
// Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am
|
||||||
|
// Commander-PC gemessen:
|
||||||
|
//
|
||||||
|
// 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den.
|
||||||
|
// 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) —
|
||||||
|
// VOR dem System, damit eine gepflegte Fassung eine alte Installation
|
||||||
|
// schlägt.
|
||||||
|
// 3. PATH — der „selbst eingetragen"-Fall.
|
||||||
|
// 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall.
|
||||||
|
// 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER
|
||||||
|
// (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den
|
||||||
|
// Programmordner.
|
||||||
|
//
|
||||||
|
// ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern
|
||||||
|
// PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte
|
||||||
|
// Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen
|
||||||
|
// (katalog.py, 28.08.2026).
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
// Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade,
|
||||||
|
// und die CI-Tests laufen auf Linux — dort würde das plattformabhängige
|
||||||
|
// join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226).
|
||||||
|
import { win32 as pfad } from 'node:path'
|
||||||
|
|
||||||
|
const { delimiter, join } = pfad
|
||||||
|
|
||||||
|
export interface Werkzeug {
|
||||||
|
titel: string
|
||||||
|
zweck: string
|
||||||
|
programme: readonly string[]
|
||||||
|
orte: readonly string[]
|
||||||
|
/** Teilstring des DisplayName im Uninstall-Zweig der Registry. */
|
||||||
|
anzeige: string
|
||||||
|
pflicht: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WERKZEUGE: Record<string, Werkzeug> = {
|
||||||
|
makemkv: {
|
||||||
|
titel: 'MakeMKV',
|
||||||
|
zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.',
|
||||||
|
programme: ['makemkvcon64.exe', 'makemkvcon.exe'],
|
||||||
|
orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'],
|
||||||
|
anzeige: 'MakeMKV',
|
||||||
|
pflicht: true,
|
||||||
|
},
|
||||||
|
handbrake: {
|
||||||
|
titel: 'HandBrakeCLI',
|
||||||
|
zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.',
|
||||||
|
programme: ['HandBrakeCLI.exe'],
|
||||||
|
orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'],
|
||||||
|
anzeige: 'HandBrake',
|
||||||
|
pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression
|
||||||
|
},
|
||||||
|
flac: {
|
||||||
|
titel: 'FLAC',
|
||||||
|
zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.',
|
||||||
|
programme: ['flac.exe'],
|
||||||
|
orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'],
|
||||||
|
anzeige: 'FLAC',
|
||||||
|
pflicht: false, // ohne FLAC läuft alles außer Audio-CDs
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */
|
||||||
|
export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.'
|
||||||
|
return join(wurzel, 'Rippy', 'tools')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** %ProgramFiles%\X → C:\Program Files\X — Groß/Klein wird IGNORIERT.
|
||||||
|
* Bleibt ein % stehen, ist der Kandidat unbrauchbar → ''. */
|
||||||
|
export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string {
|
||||||
|
let ergebnis = pfad
|
||||||
|
for (const [name, wert] of Object.entries(umgebung)) {
|
||||||
|
if (wert === undefined) continue
|
||||||
|
const marke = `%${name}%`.toUpperCase()
|
||||||
|
for (;;) {
|
||||||
|
const stelle = ergebnis.toUpperCase().indexOf(marke)
|
||||||
|
if (stelle < 0) break
|
||||||
|
ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis.includes('%') ? '' : ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */
|
||||||
|
export function kandidaten(
|
||||||
|
name: string,
|
||||||
|
eigenerOrdner: string,
|
||||||
|
umgebung: NodeJS.ProcessEnv,
|
||||||
|
): string[] {
|
||||||
|
const werkzeug = WERKZEUGE[name]
|
||||||
|
const pfade: string[] = []
|
||||||
|
// Das MITGELIEFERTE Werkzeug (§ 9) — im installierten Rippy zeigt
|
||||||
|
// RIPPY_VENDOR auf resources\vendor (der Haupt setzt es beim Kern-Start).
|
||||||
|
const vendor = umgebung['RIPPY_VENDOR'] ?? ''
|
||||||
|
if (vendor.length > 0) {
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(vendor, name, programm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(eigenerOrdner, name, programm))
|
||||||
|
pfade.push(join(eigenerOrdner, programm))
|
||||||
|
}
|
||||||
|
for (const ort of werkzeug.orte) {
|
||||||
|
const entfaltet = entfalten(ort, umgebung)
|
||||||
|
if (entfaltet.length === 0) continue
|
||||||
|
for (const programm of werkzeug.programme) {
|
||||||
|
pfade.push(join(entfaltet, programm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pfade
|
||||||
|
}
|
||||||
|
|
||||||
|
function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string {
|
||||||
|
const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? ''
|
||||||
|
for (const ordner of pfadVar.split(delimiter)) {
|
||||||
|
if (ordner.length === 0) continue
|
||||||
|
for (const programm of programme) {
|
||||||
|
const kandidat = join(ordner, programm)
|
||||||
|
if (existsSync(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist
|
||||||
|
* InstallLocation leer — DisplayIcon zeigt auf eine Datei im
|
||||||
|
* Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit
|
||||||
|
* kein zweiter koffi-Ort entsteht (R1). */
|
||||||
|
export function ausUninstallRegistry(
|
||||||
|
anzeige: string,
|
||||||
|
programme: readonly string[],
|
||||||
|
ausfuehren: (wurzel: string) => string = regQuery,
|
||||||
|
): string {
|
||||||
|
const wurzeln = [
|
||||||
|
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
|
]
|
||||||
|
for (const wurzel of wurzeln) {
|
||||||
|
const ausgabe = ausfuehren(wurzel)
|
||||||
|
// reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT.
|
||||||
|
let passt = false
|
||||||
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
|
if (zeile.startsWith('HK')) {
|
||||||
|
passt = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile)
|
||||||
|
if (wert === null) continue
|
||||||
|
if (wert[1] === 'DisplayName') {
|
||||||
|
passt = wert[2].includes(anzeige)
|
||||||
|
} else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) {
|
||||||
|
const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '')
|
||||||
|
for (const programm of programme) {
|
||||||
|
const kandidat = join(ordner, programm)
|
||||||
|
if (existsSync(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function regQuery(wurzel: string): string {
|
||||||
|
const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
return ergebnis.stdout ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Pfad zum Werkzeug — oder '' wenn es nicht da ist. Prüf-Funktionen
|
||||||
|
* einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */
|
||||||
|
export function finden(
|
||||||
|
name: string,
|
||||||
|
eingestellt = '',
|
||||||
|
eigenerOrdner: string = werkzeugOrdner(),
|
||||||
|
umgebung: NodeJS.ProcessEnv = process.env,
|
||||||
|
existiert: (pfad: string) => boolean = existsSync,
|
||||||
|
registry: typeof ausUninstallRegistry = ausUninstallRegistry,
|
||||||
|
): string {
|
||||||
|
// 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird
|
||||||
|
// NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will
|
||||||
|
// genau den, und ein Tippfehler soll als Fehler sichtbar werden.
|
||||||
|
if (eingestellt.trim().length > 0) {
|
||||||
|
return existiert(eingestellt.trim()) ? eingestellt.trim() : ''
|
||||||
|
}
|
||||||
|
// 2 + 4. Eigener Ordner, dann bekannte Orte.
|
||||||
|
for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) {
|
||||||
|
if (existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
// 3. PATH.
|
||||||
|
const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung)
|
||||||
|
if (imPfad.length > 0) return imPfad
|
||||||
|
// 5. Registry-Rückfall.
|
||||||
|
return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user