Compare commits
52
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 |
@@ -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
+1003
-5
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)}
|
||||||
|
|||||||
+470
-48
@@ -6,7 +6,6 @@ from typing import List, Optional, Dict
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -122,11 +121,14 @@ async def startup_event():
|
|||||||
|
|
||||||
ereignis_waechter = Waechter(
|
ereignis_waechter = Waechter(
|
||||||
db, ereignis_bus,
|
db, ereignis_bus,
|
||||||
laufwerke_lesen=lambda: [
|
# MIT der erkannten Disc — sonst meldet die Server-Status-Kachel
|
||||||
device_discovery.device_info(p)
|
# „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026).
|
||||||
for p in device_discovery.list_optical_devices()
|
laufwerke_lesen=laufwerke_mit_disc,
|
||||||
],
|
|
||||||
system_lesen=system_stand_sync,
|
system_lesen=system_stand_sync,
|
||||||
|
# DIESELBE Uebersetzung wie /jobs — sonst tragen die Live-Ereignisse
|
||||||
|
# andere Feldnamen als der Schnappschuss (Befund 29.08.2026:
|
||||||
|
# „1.1.1970" und „DISC" in der Jobliste).
|
||||||
|
job_form=lambda z: _job_row_to_model(z).model_dump(),
|
||||||
)
|
)
|
||||||
asyncio.create_task(ereignis_waechter.schleife())
|
asyncio.create_task(ereignis_waechter.schleife())
|
||||||
|
|
||||||
@@ -184,9 +186,27 @@ def _duplikat_suchen(fingerprint: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _auto_prescan(pfad: str):
|
async def _auto_prescan(pfad: str):
|
||||||
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
|
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis.
|
||||||
|
|
||||||
|
## Warum ein laufender Job hier alles stoppt (Befund 29.08.2026)
|
||||||
|
|
||||||
|
> „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist bereits bei
|
||||||
|
> 30% und er liest immernoch."
|
||||||
|
|
||||||
|
Genau so war es — und die Erkennung kam auch nie zum Ende. Waehrend eines
|
||||||
|
Rips haelt `makemkvcon` das Laufwerk; ein zweites `makemkvcon info`
|
||||||
|
daneben wartet, bis es seine Zeitgrenze erreicht (gemessen: eine
|
||||||
|
Laufwerks-Abfrage braucht dann 14 s statt 5). Die Marke `_laeuft` blieb
|
||||||
|
solange stehen, also stand auch das Panel.
|
||||||
|
|
||||||
|
Es gibt keinen Grund, waehrend eines Rips zu scannen: Das Laufwerk ist
|
||||||
|
belegt, und **welche Disc drin ist, wissen wir bereits** — der Job laeuft
|
||||||
|
ja auf ihr.
|
||||||
|
"""
|
||||||
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
||||||
return
|
return
|
||||||
|
if await asyncio.to_thread(db.has_active_job, pfad):
|
||||||
|
return
|
||||||
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
|
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
|
||||||
try:
|
try:
|
||||||
prescan = PreScan()
|
prescan = PreScan()
|
||||||
@@ -213,6 +233,19 @@ async def _auto_prescan(pfad: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
DISC_CACHE.pop(pfad, None)
|
DISC_CACHE.pop(pfad, None)
|
||||||
print(f"Auto-Pre-Scan {pfad}: {e}")
|
print(f"Auto-Pre-Scan {pfad}: {e}")
|
||||||
|
# ... und ins PROTOKOLL, nicht nur auf die Konsole
|
||||||
|
# (Befund 30.08.2026). Der Commander: „im log steht zwar
|
||||||
|
# erkannt, aber ein start des rips ist nicht moeglich."
|
||||||
|
# Genau so war es: Die letzte Zeile war das erfolgreiche
|
||||||
|
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
|
||||||
|
# danach standen nur auf einer Konsole, die niemand sieht.
|
||||||
|
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
|
||||||
|
try:
|
||||||
|
db.add_log("warning", "watcher",
|
||||||
|
"Disc-Erkennung auf %s fehlgeschlagen: %s"
|
||||||
|
% (pfad, e))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Protokollieren darf die Wache nie anhalten
|
||||||
|
|
||||||
|
|
||||||
async def _auto_rip_wenn_aktiviert(pfad: str):
|
async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||||
@@ -225,7 +258,10 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
if not einstellungen.get("autoRipStart"):
|
if not einstellungen.get("autoRipStart"):
|
||||||
return
|
return
|
||||||
if await asyncio.to_thread(db.has_active_job, pfad):
|
# `job_offen`, nicht `has_active_job`: Waehrend der Kompression ist das
|
||||||
|
# Laufwerk zwar frei, der Vorgang aber nicht durch — und die Disc wurde
|
||||||
|
# soeben ausgeworfen. Herleitung in store.job_offen.
|
||||||
|
if await asyncio.to_thread(db.job_offen, pfad):
|
||||||
return
|
return
|
||||||
disc = DISC_CACHE.get(pfad) or {}
|
disc = DISC_CACHE.get(pfad) or {}
|
||||||
if disc.get("_laeuft"):
|
if disc.get("_laeuft"):
|
||||||
@@ -266,6 +302,51 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
start_rip(pfad, job_id, ziel)
|
start_rip(pfad, job_id, ziel)
|
||||||
|
|
||||||
|
|
||||||
|
def disc_entscheidung(schon_gesehen: bool, vorher, status) -> str:
|
||||||
|
"""Was ist zu tun? `""` | `"erststart"` | `"eingelegt"` | `"entfernt"`.
|
||||||
|
|
||||||
|
Pure Funktion — deshalb ohne Laufwerk prüfbar, und genau daran hing der
|
||||||
|
Fehler unten.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „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. In der Disc-Wache stand:
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
Damit blieb `bekannt[pfad]` ungesetzt, und im nächsten Durchlauf war
|
||||||
|
`vorher is None` — also wieder „Erststart, liegt schon eine Disc drin".
|
||||||
|
**Ein einziger fehlgeschlagener Lesevorgang löste einen neuen Vor-Scan
|
||||||
|
aus.**
|
||||||
|
|
||||||
|
Und der scheitert regelmäßig: Während der Vor-Scan läuft, hält
|
||||||
|
`makemkvcon` das Laufwerk (gemessen: `/devices` braucht dann 14 s statt
|
||||||
|
5). Also: Scan hält das Laufwerk → Statusabfrage scheitert → nächster
|
||||||
|
Durchlauf hält es für den ersten → neuer Scan. Eine Schleife, die sich
|
||||||
|
selbst am Leben hält — und weil derselbe Aufruf sein Konsolenfenster
|
||||||
|
nicht unterdrückte, blitzte bei jeder Runde eines auf.
|
||||||
|
|
||||||
|
Deshalb zwei getrennte Fragen statt einer: **Haben wir dieses Laufwerk je
|
||||||
|
gelesen?** (`schon_gesehen`) und **was war zuletzt drin?** (`vorher`). Ein
|
||||||
|
Fehlschlag beantwortet die zweite nicht — und darf die erste nicht
|
||||||
|
zurücksetzen.
|
||||||
|
"""
|
||||||
|
if not schon_gesehen:
|
||||||
|
return "erststart" if status == CDS_DISC_OK else ""
|
||||||
|
if status == vorher:
|
||||||
|
return ""
|
||||||
|
if status == CDS_DISC_OK:
|
||||||
|
return "eingelegt"
|
||||||
|
if status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
|
||||||
|
return "entfernt"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def disc_watcher():
|
async def disc_watcher():
|
||||||
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
|
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
|
||||||
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
|
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
|
||||||
@@ -275,23 +356,43 @@ async def disc_watcher():
|
|||||||
für den Heim-Use-Case gleichwertig und läuft überall.
|
für den Heim-Use-Case gleichwertig und läuft überall.
|
||||||
"""
|
"""
|
||||||
bekannt: Dict[str, int] = {}
|
bekannt: Dict[str, int] = {}
|
||||||
|
# Laufwerke, die schon einmal ERFOLGREICH gelesen wurden. Getrennt von
|
||||||
|
# `bekannt`, weil ein fehlgeschlagener Lesevorgang sonst wie ein
|
||||||
|
# Erststart aussieht — siehe unten.
|
||||||
|
gesehen: set = set()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for pfad in device_discovery.list_optical_devices():
|
for pfad in device_discovery.list_optical_devices():
|
||||||
try:
|
try:
|
||||||
status = await asyncio.to_thread(drive_status, pfad)
|
status = await asyncio.to_thread(drive_status, pfad)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# ⚠️ Hier stand nur `continue` (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# `bekannt[pfad]` blieb damit ungesetzt, und beim nächsten
|
||||||
|
# Durchlauf war `vorher is None` — also wieder „Erststart,
|
||||||
|
# liegt schon eine Disc drin". Ein einziger fehlgeschlagener
|
||||||
|
# Lesevorgang bewirkte einen NEUEN Vor-Scan.
|
||||||
|
#
|
||||||
|
# Und genau das passiert regelmäßig: Während der Vor-Scan
|
||||||
|
# läuft, hält `makemkvcon` das Laufwerk, und die
|
||||||
|
# Statusabfrage kommt nicht durch. Ergebnis: Scan hält das
|
||||||
|
# Laufwerk → Statusabfrage scheitert → nächster Durchlauf
|
||||||
|
# hält es für den ersten → neuer Scan. Eine Schleife, die
|
||||||
|
# sich selbst am Leben hält, und bei jeder Runde blitzte
|
||||||
|
# eine Konsole auf.
|
||||||
|
#
|
||||||
|
# `gesehen` merkt sich, ob wir dieses Laufwerk je gelesen
|
||||||
|
# haben. Ein Fehlschlag heißt jetzt „nichts Neues erfahren"
|
||||||
|
# — nicht „zum ersten Mal gesehen".
|
||||||
continue
|
continue
|
||||||
vorher = bekannt.get(pfad)
|
was = disc_entscheidung(pfad in gesehen, bekannt.get(pfad), status)
|
||||||
if vorher is None:
|
gesehen.add(pfad)
|
||||||
# Erststart: liegt schon eine Disc drin, direkt erkennen
|
if was == "erststart":
|
||||||
if status == CDS_DISC_OK:
|
|
||||||
asyncio.create_task(_auto_prescan(pfad))
|
asyncio.create_task(_auto_prescan(pfad))
|
||||||
elif status != vorher:
|
elif was == "eingelegt":
|
||||||
if status == CDS_DISC_OK:
|
|
||||||
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
|
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
|
||||||
asyncio.create_task(_auto_prescan(pfad))
|
asyncio.create_task(_auto_prescan(pfad))
|
||||||
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
|
elif was == "entfernt":
|
||||||
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
||||||
DISC_CACHE.pop(pfad, None)
|
DISC_CACHE.pop(pfad, None)
|
||||||
bekannt[pfad] = status
|
bekannt[pfad] = status
|
||||||
@@ -380,7 +481,28 @@ class Device(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
serial: Optional[str] = None
|
serial: Optional[str] = None
|
||||||
|
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
|
||||||
|
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
|
||||||
|
grund: str = ""
|
||||||
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
||||||
|
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
||||||
|
# erkennung noch läuft muss sichtbar sein")
|
||||||
|
#
|
||||||
|
# Der Zustand war schon da — `_auto_prescan` legt beim Start
|
||||||
|
# `{"_laeuft": True}` in den Vorrat. Nur wurde er nie herausgegeben:
|
||||||
|
# `laufwerke_mit_disc` ließ so einen Eintrag KOMPLETT weg, damit keine
|
||||||
|
# halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem
|
||||||
|
# „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch gelöst — für
|
||||||
|
# die Oberfläche sah es dadurch aus, als läge gar keine Disc im Laufwerk.
|
||||||
|
#
|
||||||
|
# Und das dauert: `makemkvcon info` läuft an einer Blu-ray in seine
|
||||||
|
# 120-Sekunden-Grenze (gemessen 29.08.2026 — Disc nach 119 s erkannt).
|
||||||
|
# Zwei Minuten Schweigen sehen aus wie ein Fehler.
|
||||||
|
#
|
||||||
|
# Ein eigenes Feld statt der halben Disc: Die Oberfläche kann „wird
|
||||||
|
# erkannt" zeigen, ohne einen Titel behaupten zu müssen, den es noch
|
||||||
|
# nicht gibt.
|
||||||
|
disc_wird_erkannt: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _job_row_to_model(zeile: dict) -> Job:
|
def _job_row_to_model(zeile: dict) -> Job:
|
||||||
@@ -434,7 +556,10 @@ async def _snapshot() -> dict:
|
|||||||
"""
|
"""
|
||||||
def sammeln():
|
def sammeln():
|
||||||
return {
|
return {
|
||||||
"jobs": [_job_row_to_model(z).model_dump() for z in db.list_jobs(limit=50)],
|
# DIESELBE Funktion wie /jobs — sonst fehlt hier die
|
||||||
|
# Restzeit, und die Oberflaeche liest seit V2-3 nur noch
|
||||||
|
# diesen Schnappschuss (Befund 29.08.2026).
|
||||||
|
"jobs": [m.model_dump() for m in jobs_fuer_ui(limit=50)],
|
||||||
"workers": db.list_workers(),
|
"workers": db.list_workers(),
|
||||||
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
|
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
|
||||||
}
|
}
|
||||||
@@ -443,16 +568,25 @@ async def _snapshot() -> dict:
|
|||||||
# Laufwerke getrennt: device_info macht ioctls, die hängen können —
|
# Laufwerke getrennt: device_info macht ioctls, die hängen können —
|
||||||
# ein defektes Laufwerk darf den Snapshot nicht aufhalten.
|
# ein defektes Laufwerk darf den Snapshot nicht aufhalten.
|
||||||
try:
|
try:
|
||||||
|
# MIT der erkannten Disc. Hier stand dieselbe Schleife ein DRITTES
|
||||||
|
# Mal (Befund 29.08.2026) — `/devices` hängte die Disc an, der
|
||||||
|
# Ereignis-Wächter und dieser Schnappschuss nicht. Das Dashboard
|
||||||
|
# liest genau diesen Schnappschuss und schloss aus der fehlenden
|
||||||
|
# Disc auf ein leeres Laufwerk: „Bereit — kein Datenträger im
|
||||||
|
# Laufwerk", während der Titel eine Zeile weiter oben stand.
|
||||||
zustand["devices"] = await asyncio.wait_for(
|
zustand["devices"] = await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(laufwerke_mit_disc), timeout=5)
|
||||||
lambda: [device_discovery.device_info(p)
|
|
||||||
for p in device_discovery.list_optical_devices()]),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except (asyncio.TimeoutError, OSError):
|
except (asyncio.TimeoutError, OSError):
|
||||||
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||||
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
|
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
|
||||||
zustand["devices"] = None
|
#
|
||||||
|
# Vorher stand hier NUR `None`. Waehrend der Disc-Erkennung ist das
|
||||||
|
# aber der Normalfall (makemkvcon haelt das Laufwerk, /devices
|
||||||
|
# braucht dann 14 s statt 5) — 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. `laufwerke_notdurft` antwortet ohne ioctl.
|
||||||
|
zustand["devices"] = laufwerke_notdurft()
|
||||||
|
|
||||||
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
|
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
|
||||||
#
|
#
|
||||||
@@ -671,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
|||||||
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
|
||||||
|
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
|
||||||
|
|
||||||
|
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
|
||||||
|
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
|
||||||
|
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
|
||||||
|
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
|
||||||
|
HEUTIGEN Wert der Einstellung `workDir`.
|
||||||
|
|
||||||
|
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
|
||||||
|
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
|
||||||
|
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
|
||||||
|
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
|
||||||
|
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
|
||||||
|
|
||||||
|
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
|
||||||
|
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
|
||||||
|
wurde), statt nachzusehen. Der Job weiß es selbst.
|
||||||
|
"""
|
||||||
|
holen = getattr(job, "get", None)
|
||||||
|
eigen = ""
|
||||||
|
if holen:
|
||||||
|
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
|
||||||
|
if not eigen:
|
||||||
|
werte = einstellungen if einstellungen is not None else db.get_settings()
|
||||||
|
eigen = ((werte or {}).get("workDir") or "").strip()
|
||||||
|
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
|
||||||
|
# gemeint. "/" fällt in `kandidaten()` sauber durch.
|
||||||
|
return os.path.normpath(eigen or "/")
|
||||||
|
|
||||||
|
|
||||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||||
@@ -797,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
|
|||||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||||
"""
|
"""
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
einstellungen = db.get_settings()
|
||||||
alt = _ROHDATEN["treffer"]
|
alt = _ROHDATEN["treffer"]
|
||||||
treffer = {}
|
treffer = {}
|
||||||
for zeile in db.list_jobs():
|
for zeile in db.list_jobs():
|
||||||
if zeile.get("status") != "failed":
|
if zeile.get("status") != "failed":
|
||||||
continue
|
continue
|
||||||
job_id = zeile["id"]
|
job_id = zeile["id"]
|
||||||
|
# Je Job SEINE Wahl — nicht die heutige Einstellung.
|
||||||
|
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
|
||||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||||
@@ -836,8 +1005,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
if job["id"] in vorrat:
|
if job["id"] in vorrat:
|
||||||
return bool(vorrat[job["id"]])
|
return bool(vorrat[job["id"]])
|
||||||
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
||||||
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
|
# dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel
|
||||||
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
|
# kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"]))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
@@ -850,11 +1022,43 @@ async def get_jobs():
|
|||||||
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
||||||
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
||||||
"""
|
"""
|
||||||
def sammle():
|
return await asyncio.to_thread(jobs_fuer_ui)
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
|
||||||
|
|
||||||
|
def jobs_fuer_ui(limit: int = 100) -> list:
|
||||||
|
"""Die Jobs in UI-Form — MIT Restzeit, Wiederhol-Art und Rohdaten-Frage.
|
||||||
|
|
||||||
|
## Warum das eine Funktion ist (Befund 29.08.2026)
|
||||||
|
|
||||||
|
> „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||||
|
> messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
|
||||||
|
Zwei Ursachen, beide davon, dass es die Jobliste ZWEIMAL gab.
|
||||||
|
|
||||||
|
**1.** Diese Rechnung stand nur in `/jobs`. Der SSE-Schnappschuss baute
|
||||||
|
seine Jobs mit dem nackten `_job_row_to_model` — also ohne Restzeit. Seit
|
||||||
|
der Umstellung auf den Ereignisstrom (V2-3) liest die Oberfläche aber
|
||||||
|
genau diesen Schnappschuss und nicht mehr `/jobs`. Die Restzeit wurde also
|
||||||
|
weiterhin brav berechnet und niemandem gezeigt.
|
||||||
|
|
||||||
|
**2.** Die Messreihe lag ausschließlich in Redis, das es unter Windows
|
||||||
|
nicht gibt — Begründung und Rückfall in `eta.py`.
|
||||||
|
|
||||||
|
Dieselbe Lehre wie bei `laufwerke_mit_disc`: Eine Auskunft in zwei
|
||||||
|
Fassungen ist eine Fassung zu viel.
|
||||||
|
"""
|
||||||
|
# Unlesbare Einstellungen duerfen die Jobliste NICHT umwerfen — sie
|
||||||
|
# steuern hier nur, ob „Neu komprimieren" angeboten wird. Seit diese
|
||||||
|
# Funktion auch den SSE-Schnappschuss baut, haengt daran die ganze
|
||||||
|
# Oberflaeche (Befund 29.08.2026: zwei Snapshot-Tests wurden dadurch rot).
|
||||||
|
try:
|
||||||
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
einstellungen = {}
|
||||||
|
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||||
jetzt = time.monotonic()
|
jetzt = time.monotonic()
|
||||||
modelle = []
|
modelle = []
|
||||||
for z in db.list_jobs():
|
for z in db.list_jobs(limit=limit):
|
||||||
modell = _job_row_to_model(z)
|
modell = _job_row_to_model(z)
|
||||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
||||||
modell.retry_art = phasen.retry_art(z)
|
modell.retry_art = phasen.retry_art(z)
|
||||||
@@ -867,8 +1071,6 @@ async def get_jobs():
|
|||||||
modelle.append(modell)
|
modelle.append(modell)
|
||||||
return modelle
|
return modelle
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
|
||||||
|
|
||||||
|
|
||||||
def _rohdaten_groesse(pfade: list) -> tuple:
|
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||||
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
|
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
|
||||||
@@ -899,8 +1101,7 @@ async def job_rohdaten(job_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
|
||||||
def sammle():
|
def sammle():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||||
return {
|
return {
|
||||||
"pfade": pfade,
|
"pfade": pfade,
|
||||||
@@ -929,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
|||||||
geloescht_gb = 0.0
|
geloescht_gb = 0.0
|
||||||
if rohdaten:
|
if rohdaten:
|
||||||
def raeume():
|
def raeume():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||||
for pfad in pfade:
|
for pfad in pfade:
|
||||||
shutil.rmtree(pfad, ignore_errors=True)
|
shutil.rmtree(pfad, ignore_errors=True)
|
||||||
@@ -1124,6 +1324,14 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
# Eine noch laufende Disc-Erkennung ist ab jetzt gegenstandslos: Das
|
||||||
|
# Laufwerk gehoert dem Rip, und WELCHE Disc drin ist, wissen wir. Ohne
|
||||||
|
# das Wegraeumen stuende „Disc wird gelesen" bis zum Ende des Rips
|
||||||
|
# (Commander 29.08.2026: „Der Rip ist bereits bei 30% und er liest
|
||||||
|
# immernoch"). Der laufende Scan traegt sein Ergebnis spaeter ohnehin
|
||||||
|
# selbst nach.
|
||||||
|
if (DISC_CACHE.get(device_path) or {}).get("_laeuft"):
|
||||||
|
DISC_CACHE.pop(device_path, None)
|
||||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log, "info", "api",
|
db.add_log, "info", "api",
|
||||||
@@ -1317,12 +1525,35 @@ async def storage_targets():
|
|||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
def geraetepfad(name: str) -> str:
|
||||||
|
"""Die Kennung aus der URL zum echten Geraetepfad — oder 404.
|
||||||
|
|
||||||
|
## Der Befund (29.08.2026)
|
||||||
|
|
||||||
|
Hier stand an DREI Stellen:
|
||||||
|
|
||||||
|
device_path = f"/dev/{name}"
|
||||||
|
|
||||||
|
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf —
|
||||||
|
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
|
||||||
|
keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter
|
||||||
|
Windows also immer mit 404**, ohne dass irgendwo stuende, warum.
|
||||||
|
|
||||||
|
Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`),
|
||||||
|
also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht
|
||||||
|
mehr auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
pfad = device_discovery.pfad_zu_kennung(name)
|
||||||
|
if not pfad:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"Laufwerk {name} nicht gefunden")
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
@app.post("/devices/{name}/eject")
|
@app.post("/devices/{name}/eject")
|
||||||
async def eject_device(name: str):
|
async def eject_device(name: str):
|
||||||
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
|
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
if device_path not in device_discovery.list_optical_devices():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
|
||||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
|
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
|
||||||
@@ -1342,9 +1573,7 @@ async def scan_tracks_starten(name: str):
|
|||||||
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
||||||
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
||||||
"""
|
"""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
if device_path not in device_discovery.list_optical_devices():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
|
||||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||||
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||||
|
|
||||||
@@ -1356,7 +1585,7 @@ async def scan_tracks_starten(name: str):
|
|||||||
@app.get("/devices/{name}/tracks")
|
@app.get("/devices/{name}/tracks")
|
||||||
async def scan_tracks_ergebnis(name: str):
|
async def scan_tracks_ergebnis(name: str):
|
||||||
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
||||||
if not daten:
|
if not daten:
|
||||||
return {"status": "none"}
|
return {"status": "none"}
|
||||||
@@ -1384,7 +1613,7 @@ async def retry_transcode(job_id: str):
|
|||||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||||
|
|
||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
|
||||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||||
if not gefunden:
|
if not gefunden:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -2348,6 +2577,13 @@ async def system_info():
|
|||||||
werte = rippy_config.laden()
|
werte = rippy_config.laden()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
werte = {}
|
werte = {}
|
||||||
|
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
|
||||||
|
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
|
||||||
|
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
|
||||||
|
# was eingestellt war (Befund 30.08.2026, siehe
|
||||||
|
# `betrieb.mit_einstellungen`).
|
||||||
|
werte = betriebs_auskunft.mit_einstellungen(
|
||||||
|
werte, db.get_settings(bei_fehler_leer=True))
|
||||||
for ort in betriebs_auskunft.platz_orte(werte):
|
for ort in betriebs_auskunft.platz_orte(werte):
|
||||||
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
||||||
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
||||||
@@ -2361,6 +2597,12 @@ async def system_info():
|
|||||||
continue
|
continue
|
||||||
info["plaetze"].append({
|
info["plaetze"].append({
|
||||||
"name": ort["name"],
|
"name": ort["name"],
|
||||||
|
# Der PFAD gehoert dazu (Commander 29.08.2026): „bei ‚Platz
|
||||||
|
# fuer rippy' sollte eher das arbeitsverzeichnis und der
|
||||||
|
# Ablagepfad sein." Eine Zahl ohne Ort sagt nicht, WO der
|
||||||
|
# Platz knapp wird.
|
||||||
|
"pfad": ort["pfad"],
|
||||||
|
"gleiches_laufwerk": bool(ort.get("gleiches_laufwerk")),
|
||||||
"frei_gb": round(nutzung.free / 1024**3, 1),
|
"frei_gb": round(nutzung.free / 1024**3, 1),
|
||||||
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
||||||
})
|
})
|
||||||
@@ -2445,14 +2687,30 @@ async def makemkv_key_holen():
|
|||||||
detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
|
detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
|
||||||
"Vermutlich hat sich die Seite geändert.")
|
"Vermutlich hat sich die Seite geändert.")
|
||||||
geaendert = await asyncio.to_thread(key_modul._apply_key, key)
|
geaendert = await asyncio.to_thread(key_modul._apply_key, key)
|
||||||
|
|
||||||
|
# Nimmt MakeMKV ihn an? Nur MakeMKV selbst weiss das.
|
||||||
|
#
|
||||||
|
# Am 29.08.2026 auf dem Rechner des Commanders: der frische Schluessel
|
||||||
|
# wurde mit MSG 5020 („ungueltig") und 5021 („Programmversion zu alt")
|
||||||
|
# abgelehnt — sein MakeMKV 1.18.4 ist aelter als der Schluessel verlangt.
|
||||||
|
# Ohne diese Rueckfrage haette hier „erfolgreich geholt" gestanden, und
|
||||||
|
# der naechste Rip waere trotzdem gescheitert.
|
||||||
|
from rippy.rip import makemkv_aufruf
|
||||||
|
from rippy.tools import katalog as werkzeug_katalog
|
||||||
|
|
||||||
|
urteil = await asyncio.to_thread(
|
||||||
|
makemkv_aufruf.key_setzen_und_pruefen, key,
|
||||||
|
werkzeug_katalog.finden("makemkv"))
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log, "success", "makemkv-key",
|
db.add_log, "success" if urteil["ok"] else "warning", "makemkv-key",
|
||||||
"Beta-Key aus dem Forum geholt (…%s)%s"
|
"Beta-Key aus dem Forum geholt (…%s)%s%s"
|
||||||
% (key[-6:], "" if geaendert else " — war schon der aktuelle"))
|
% (key[-6:], "" if geaendert else " — war schon der aktuelle",
|
||||||
|
"" if urteil["ok"] else " — ABGELEHNT: " + urteil["grund"]))
|
||||||
# Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
|
# Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
|
||||||
# und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
|
# und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
|
||||||
# verlieren.
|
# verlieren.
|
||||||
return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:]}
|
return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:],
|
||||||
|
"angenommen": urteil["ok"], "grund": urteil["grund"]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/system/keydb")
|
@app.post("/system/keydb")
|
||||||
@@ -2705,16 +2963,180 @@ async def get_devices():
|
|||||||
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
|
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
|
||||||
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
|
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
|
||||||
"""
|
"""
|
||||||
|
return [Device(**info) for info in
|
||||||
|
await asyncio.to_thread(laufwerke_mit_disc)]
|
||||||
|
|
||||||
|
|
||||||
|
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
|
||||||
|
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
|
||||||
|
_LETZTER_GRUND: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _grund_melden(pfad: str, grund: str) -> None:
|
||||||
|
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
|
||||||
|
Wechsel.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar
|
||||||
|
> erkannt, aber ein start des rips ist nicht moeglich)"
|
||||||
|
|
||||||
|
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
|
||||||
|
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon.
|
||||||
|
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
|
||||||
|
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
|
||||||
|
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
|
||||||
|
|
||||||
|
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
|
||||||
|
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
|
||||||
|
beschreiben, sondern weiterhelfen.
|
||||||
|
"""
|
||||||
|
if _LETZTER_GRUND.get(pfad, "") == grund:
|
||||||
|
return
|
||||||
|
vorher = _LETZTER_GRUND.get(pfad, "")
|
||||||
|
_LETZTER_GRUND[pfad] = grund
|
||||||
|
try:
|
||||||
|
if grund:
|
||||||
|
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
|
||||||
|
elif vorher:
|
||||||
|
db.add_log("info", "watcher",
|
||||||
|
"Laufwerk %s antwortet wieder." % pfad)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Protokollieren darf die Laufwerksliste nie aufhalten
|
||||||
|
|
||||||
|
|
||||||
|
def _job_haelt_das_laufwerk(pfad: str) -> bool:
|
||||||
|
"""Laeuft auf diesem Laufwerk gerade ein Rip?
|
||||||
|
|
||||||
|
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
|
||||||
|
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
|
||||||
|
gehoert. Am Protokoll des Commanders abgelesen:
|
||||||
|
|
||||||
|
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 MSG 5010 Das Oeffnen der Disk schlug fehl
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
|
||||||
|
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
|
||||||
|
|
||||||
|
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
|
||||||
|
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
|
||||||
|
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
|
||||||
|
bereits, was drinliegt, der Job laeuft ja darauf.
|
||||||
|
|
||||||
|
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
|
||||||
|
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return bool(db.has_active_job(pfad))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False # im Zweifel nachsehen, wie bisher
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerke_mit_disc() -> list:
|
||||||
|
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht"
|
||||||
|
|
||||||
|
Auf der Server-Status-Kachel stand „Bereit — kein Datenträger im
|
||||||
|
Laufwerk", während die Disc erkannt war und ihr Titel eine Zeile weiter
|
||||||
|
oben in der Jobliste stand.
|
||||||
|
|
||||||
|
Die Ursache: Es gab ZWEI Wege zu den Laufwerken. `/devices` hängte die
|
||||||
|
erkannte Disc aus `DISC_CACHE` an; der Ereignis-Wächter rief nur
|
||||||
|
`device_info()` und ließ sie weg. Das UI liest seit V2-3 den Ereignisstrom
|
||||||
|
— also die Fassung ohne Disc. Es fragte `l.disc?.title`, bekam nichts, und
|
||||||
|
schloss daraus auf ein leeres Laufwerk.
|
||||||
|
|
||||||
|
Eine Auskunft in zwei Fassungen ist eine Auskunft zu viel. Jetzt gibt es
|
||||||
|
nur diese hier.
|
||||||
|
"""
|
||||||
geraete = []
|
geraete = []
|
||||||
|
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
|
||||||
for pfad in device_discovery.list_optical_devices():
|
for pfad in device_discovery.list_optical_devices():
|
||||||
info = await asyncio.to_thread(device_discovery.device_info, pfad)
|
if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
|
||||||
|
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
|
||||||
|
info = {k: v for k, v in letzte[pfad].items()
|
||||||
|
if k not in ("disc", "disc_wird_erkannt")}
|
||||||
|
else:
|
||||||
|
info = device_discovery.device_info(pfad)
|
||||||
|
_grund_melden(pfad, info.get("grund") or "")
|
||||||
disc = DISC_CACHE.get(pfad)
|
disc = DISC_CACHE.get(pfad)
|
||||||
if disc and not disc.get("_laeuft"):
|
if disc and disc.get("_laeuft"):
|
||||||
|
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
||||||
|
# sagen, dass gerade gearbeitet wird: Zwei Minuten Schweigen
|
||||||
|
# sehen aus wie ein leeres Laufwerk (Commander 29.08.2026).
|
||||||
|
info["disc_wird_erkannt"] = True
|
||||||
|
elif disc:
|
||||||
info["disc"] = disc
|
info["disc"] = disc
|
||||||
geraete.append(Device(**info))
|
geraete.append(info)
|
||||||
|
LETZTE_LAUFWERKE[:] = geraete
|
||||||
return geraete
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
|
#: Der zuletzt vollständig gelesene Laufwerks-Stand. Siehe `laufwerke_notdurft`.
|
||||||
|
LETZTE_LAUFWERKE: List[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerke_notdurft() -> Optional[list]:
|
||||||
|
"""Was sich OHNE Laufwerks-Abfrage sagen lässt — oder None.
|
||||||
|
|
||||||
|
## Warum es diesen Rückfall gibt (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander wollte sehen, dass die Disc-Erkennung noch läuft. Genau
|
||||||
|
dann ist die Auskunft aber am teuersten: Während `makemkvcon info` die
|
||||||
|
Disc liest, hält es das Laufwerk, und `device_info` wartet mit. Gemessen:
|
||||||
|
|
||||||
|
/devices waehrend des Scans 14,0 s
|
||||||
|
Zeitgrenze des Schnappschusses 5,0 s -> devices = None
|
||||||
|
|
||||||
|
`None` heißt „konnte nicht nachsehen", und die Oberfläche behält dann
|
||||||
|
ihren Stand — nach einem frischen Laden also **gar nichts**. Ausgerechnet
|
||||||
|
in der Phase, die sichtbar werden sollte, war der Bildschirm leer.
|
||||||
|
|
||||||
|
Der Ausweg braucht kein ioctl: Dass gerade erkannt wird, steht im
|
||||||
|
Vorrat (`DISC_CACHE`), und wie die Laufwerke heißen, wissen wir vom
|
||||||
|
letzten vollständigen Lauf. Beides zusammen ist eine ehrliche Auskunft —
|
||||||
|
„das sahen wir zuletzt, und an DIESEM Laufwerk arbeiten wir gerade".
|
||||||
|
|
||||||
|
`None`, wenn wir noch nie erfolgreich gelesen haben: Dann ist Schweigen
|
||||||
|
richtig, denn behaupten ließe sich nichts.
|
||||||
|
"""
|
||||||
|
bekannt = LETZTE_LAUFWERKE
|
||||||
|
if not bekannt:
|
||||||
|
# Kaltstart mitten in der Erkennung: Wir haben noch keinen Stand.
|
||||||
|
# Die LISTE der Laufwerke ist trotzdem billig (sie zaehlt nur
|
||||||
|
# Buchstaben bzw. /dev-Knoten auf) — teuer ist erst `device_info`,
|
||||||
|
# das das Laufwerk selbst anfasst. Also das Wenige melden, das
|
||||||
|
# sicher ist, und nichts erfinden: kein Typ, kein Modell.
|
||||||
|
try:
|
||||||
|
pfade_jetzt = device_discovery.list_optical_devices()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bekannt = [{"id": p.rstrip(":\\").rsplit("\\", 1)[-1].rstrip(":") or p,
|
||||||
|
"name": p, "path": p, "type": "unknown", "status": "ready"}
|
||||||
|
for p in pfade_jetzt if (DISC_CACHE.get(p) or {}).get("_laeuft")]
|
||||||
|
if not bekannt:
|
||||||
|
return None
|
||||||
|
frisch = []
|
||||||
|
for alt in bekannt:
|
||||||
|
eintrag = dict(alt)
|
||||||
|
disc = DISC_CACHE.get(eintrag.get("path")) or {}
|
||||||
|
eintrag.pop("disc_wird_erkannt", None)
|
||||||
|
eintrag.pop("disc", None)
|
||||||
|
if disc.get("_laeuft"):
|
||||||
|
eintrag["disc_wird_erkannt"] = True
|
||||||
|
elif disc:
|
||||||
|
eintrag["disc"] = disc
|
||||||
|
frisch.append(eintrag)
|
||||||
|
return frisch
|
||||||
|
|
||||||
|
|
||||||
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
||||||
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
||||||
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -113,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
|
||||||
|
|
||||||
@@ -329,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():
|
||||||
@@ -353,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
|
||||||
|
|||||||
+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())
|
||||||
|
|
||||||
@@ -411,3 +419,516 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
|||||||
# 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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
|
||||||
const betrieb = useBetrieb()
|
const betrieb = useBetrieb()
|
||||||
|
|
||||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
/*
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
*
|
||||||
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
|
* 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 [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[]>([])
|
||||||
@@ -223,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 || betrieb.ablage_vorgabe || '/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 },
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -77,8 +77,11 @@ const VORGABE: Betrieb = {
|
|||||||
werkzeuge_verwalten: false,
|
werkzeuge_verwalten: false,
|
||||||
frei_blaettern: false,
|
frei_blaettern: false,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: '/app/media',
|
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||||
arbeits_vorgabe: '/app/temp',
|
// 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -270,10 +271,19 @@ export default function SettingsPage() {
|
|||||||
const r = await api.post('/system/makemkv-key/holen')
|
const r = await api.post('/system/makemkv-key/holen')
|
||||||
const antwort = await api.get('/settings')
|
const antwort = await api.get('/settings')
|
||||||
setSettings({ ...defaultSettings, ...antwort.data })
|
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
|
setKeyMeldung(r.data?.geaendert
|
||||||
? `Neuer Key geholt (endet auf …${r.data.endet_auf}).`
|
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||||||
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||||||
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const grund = e?.response?.data?.detail
|
const grund = e?.response?.data?.detail
|
||||||
|| 'Das Forum ist nicht erreichbar.'
|
|| 'Das Forum ist nicht erreichbar.'
|
||||||
@@ -991,11 +1001,11 @@ export default function SettingsPage() {
|
|||||||
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||||
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||||
*/
|
*/
|
||||||
<Input
|
<OrdnerWaehler
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
placeholder={betrieb.arbeits_vorgabe}
|
placeholder={betrieb.arbeits_vorgabe}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
@@ -1088,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
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Die MakeMKV-Schlüsselkette (KONZEPT § 9.1) — Vorlagen makemkv_key.py
|
||||||
|
// (Forum-Abruf) und makemkv_aufruf.py (Registry-Ablage + Prüfung, beides
|
||||||
|
// am Commander-PC gemessen):
|
||||||
|
//
|
||||||
|
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
||||||
|
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
||||||
|
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
||||||
|
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
||||||
|
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
||||||
|
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
||||||
|
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
||||||
|
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
||||||
|
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
||||||
|
|
||||||
|
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
||||||
|
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
|
||||||
|
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
|
const KEY_NAME = 'app_Key'
|
||||||
|
|
||||||
|
/** Den Beta-Key aus dem Forum-HTML ziehen (pur). */
|
||||||
|
export function extractKey(html: string): string {
|
||||||
|
const treffer = KEY_MUSTER.exec(html ?? '')
|
||||||
|
return treffer === null ? '' : treffer[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
||||||
|
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
||||||
|
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
|
||||||
|
export function extractGueltigBis(html: string): string {
|
||||||
|
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
|
||||||
|
html ?? '',
|
||||||
|
)
|
||||||
|
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumKey {
|
||||||
|
key: string
|
||||||
|
gueltigBis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der aktuelle Beta-Key aus dem Forum — 8-s-Zeitgrenze je Versuch: Das
|
||||||
|
* Forum fiel bei der Messung in zwei von drei Abrufen aus (§ 9). */
|
||||||
|
export async function betaKeyHolen(holen: typeof fetch = fetch): Promise<ForumKey | null> {
|
||||||
|
for (let versuch = 0; versuch < 3; versuch++) {
|
||||||
|
try {
|
||||||
|
const antwort = await holen(FORUM_URL, {
|
||||||
|
signal: AbortSignal.timeout(8_000),
|
||||||
|
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' },
|
||||||
|
})
|
||||||
|
if (antwort.status >= 300) continue
|
||||||
|
const html = await antwort.text()
|
||||||
|
const key = extractKey(html)
|
||||||
|
if (key.length > 0) return { key, gueltigBis: extractGueltigBis(html) }
|
||||||
|
} catch {
|
||||||
|
// nächster Versuch — der Fehlschlag wird am Ende als null sichtbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registry-Ablage über reg.exe (kein zweiter koffi-Ort, R1) ───────────
|
||||||
|
// In dieser Registry steckt die Arbeit des Commanders (Laufwerksprofile,
|
||||||
|
// Fensterlage). Geschrieben wird GENAU app_Key und app_UpdateEnable —
|
||||||
|
// gelöscht wird nur app_Key, und nur nach Ablehnung.
|
||||||
|
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||||
|
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||||
|
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyAblegen(key: string): boolean {
|
||||||
|
if (key.trim().length === 0) return false
|
||||||
|
return reg(['add', REGISTRY_PFAD, '/v', KEY_NAME, '/t', 'REG_SZ', '/d', key.trim(), '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyZuruecknehmen(): boolean {
|
||||||
|
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
||||||
|
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
||||||
|
export function updateKanalSicherstellen(): boolean {
|
||||||
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', 'app_UpdateEnable'])
|
||||||
|
if (vorhanden.ok) return false
|
||||||
|
return reg(['add', REGISTRY_PFAD, '/v', 'app_UpdateEnable', '/t', 'REG_DWORD', '/d', '1', '/f']).ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyUrteil {
|
||||||
|
ok: boolean
|
||||||
|
code: number | null
|
||||||
|
grund: string
|
||||||
|
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
||||||
|
* nicht erkennbar. */
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
||||||
|
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
||||||
|
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
|
||||||
|
*
|
||||||
|
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
||||||
|
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
||||||
|
* „Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
|
||||||
|
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
|
||||||
|
* den falschen. */
|
||||||
|
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
if (programm.length === 0) {
|
||||||
|
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
||||||
|
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', (fehler) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
||||||
|
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
||||||
|
const codes: number[] = []
|
||||||
|
let version = ''
|
||||||
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
|
if (!zeile.startsWith('MSG:')) continue
|
||||||
|
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||||
|
if (!Number.isInteger(code)) continue
|
||||||
|
if (code === 1005) {
|
||||||
|
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
||||||
|
if (treffer !== null) version = treffer[1]
|
||||||
|
}
|
||||||
|
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
||||||
|
}
|
||||||
|
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
|
||||||
|
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
||||||
|
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
||||||
|
const code = codes.includes(5021) ? 5021 : codes[0]
|
||||||
|
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchluesselStand {
|
||||||
|
/** 'dauerlizenz' | 'forum' | 'keiner' */
|
||||||
|
quelle: string
|
||||||
|
angenommen: boolean | null
|
||||||
|
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
|
||||||
|
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
|
||||||
|
weiterBetrieb: boolean
|
||||||
|
gueltigBis: string
|
||||||
|
geholtAm: string
|
||||||
|
text: string
|
||||||
|
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — fürs Update-Urteil. */
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
||||||
|
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
|
||||||
|
export async function schluesselKette(
|
||||||
|
makemkvcon: string,
|
||||||
|
dauerlizenz: string,
|
||||||
|
jetztIso: string,
|
||||||
|
holen: typeof fetch = fetch,
|
||||||
|
): Promise<SchluesselStand> {
|
||||||
|
updateKanalSicherstellen()
|
||||||
|
|
||||||
|
if (dauerlizenz.trim().length > 0) {
|
||||||
|
keyAblegen(dauerlizenz)
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
return {
|
||||||
|
quelle: 'dauerlizenz',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
weiterBetrieb: urteil.ok,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
||||||
|
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const forum = await betaKeyHolen(holen)
|
||||||
|
if (forum === null) {
|
||||||
|
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
||||||
|
// wird geprüft, damit der Stand ehrlich ist.
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
return {
|
||||||
|
quelle: 'keiner',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||||
|
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyAblegen(forum.key)
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.ok) {
|
||||||
|
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
||||||
|
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
||||||
|
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
|
||||||
|
keyZuruecknehmen()
|
||||||
|
}
|
||||||
|
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
|
||||||
|
return {
|
||||||
|
quelle: 'forum',
|
||||||
|
angenommen: urteil.ok,
|
||||||
|
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
|
||||||
|
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: forum.gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: urteil.ok
|
||||||
|
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||||
|
: urteil.code === 5021
|
||||||
|
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
|
||||||
|
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
||||||
|
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||||
|
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
|
||||||
|
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { baueNfo } from '../src/kern/ablage/nfo'
|
||||||
|
import {
|
||||||
|
episodenUmbenennen,
|
||||||
|
matcheEpisoden,
|
||||||
|
serienOrdner,
|
||||||
|
sichererName,
|
||||||
|
zielordner,
|
||||||
|
} from '../src/kern/ablage/struktur'
|
||||||
|
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
|
||||||
|
|
||||||
|
describe('sichererName', () => {
|
||||||
|
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
|
||||||
|
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
|
||||||
|
'Der Herr der Ringe Die Gefährten (2001)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
|
||||||
|
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
|
||||||
|
})
|
||||||
|
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
|
||||||
|
expect(sichererName('...')).toBe('')
|
||||||
|
expect(sichererName('Film.', null)).toBe('Film')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zielordner', () => {
|
||||||
|
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
|
||||||
|
// Erwartungen über join() — die Funktionen arbeiten mit den
|
||||||
|
// Pfadregeln der LAUFZEIT-Plattform (Linux-CI testet mit POSIX).
|
||||||
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
|
||||||
|
join('Filme', 'Akira (1988)'),
|
||||||
|
)
|
||||||
|
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
|
||||||
|
join('Filme', 'Akira (1988) [job-1234]'),
|
||||||
|
)
|
||||||
|
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serienOrdner', () => {
|
||||||
|
it('Jellyfin-Schema Serie/Season NN', () => {
|
||||||
|
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matcheEpisoden', () => {
|
||||||
|
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1420, 1425, 1418],
|
||||||
|
[
|
||||||
|
[1, 1420],
|
||||||
|
[2, 1420],
|
||||||
|
[3, 1420],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1200, 2400],
|
||||||
|
[
|
||||||
|
[1, 1210],
|
||||||
|
[2, 2390],
|
||||||
|
[3, 3000],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
|
||||||
|
expect(
|
||||||
|
matcheEpisoden(
|
||||||
|
[1420, 1421],
|
||||||
|
[
|
||||||
|
[1, 1420],
|
||||||
|
[2, 1420],
|
||||||
|
[3, 1420],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
it('keine Daten heißt null', () => {
|
||||||
|
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
|
||||||
|
expect(matcheEpisoden([100], [])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('episodenUmbenennen', () => {
|
||||||
|
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
|
||||||
|
const umbenannt: Array<[string, string]> = []
|
||||||
|
const meldungen = episodenUmbenennen('S', 'Akte X', 1, [3, 4], {
|
||||||
|
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: (von, nach) => umbenannt.push([von, nach]),
|
||||||
|
})
|
||||||
|
expect(umbenannt).toEqual([
|
||||||
|
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
|
||||||
|
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
|
||||||
|
])
|
||||||
|
expect(meldungen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
it('Zahl passt nicht → gar nichts anfassen', () => {
|
||||||
|
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
|
||||||
|
listen: () => ['a.mkv', 'b.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: () => {
|
||||||
|
throw new Error('darf nicht')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(meldungen[0]).toContain('übersprungen')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('baueNfo', () => {
|
||||||
|
it('movie.nfo mit XML-Escaping', () => {
|
||||||
|
const nfo = baueNfo(
|
||||||
|
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
|
||||||
|
'Akira <Director\'s Cut>',
|
||||||
|
1988,
|
||||||
|
)
|
||||||
|
expect(nfo).toContain('<movie>')
|
||||||
|
expect(nfo).toContain('<title>Akira <Director\'s Cut></title>')
|
||||||
|
expect(nfo).toContain('<plot>Roboter & Menschen</plot>')
|
||||||
|
expect(nfo).toContain('<year>1988</year>')
|
||||||
|
expect(nfo).toContain('<runtime>124</runtime>')
|
||||||
|
expect(nfo).toContain('Source: Rippy')
|
||||||
|
})
|
||||||
|
it('Serie bekommt tvshow als Wurzel', () => {
|
||||||
|
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
|
||||||
|
// HandBrake gemessen (25./26.07.2026).
|
||||||
|
describe('Encoder- und Preset-Listen', () => {
|
||||||
|
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
|
||||||
|
const hilfe = [
|
||||||
|
' -e, --encoder <string> Select video encoder:',
|
||||||
|
' svt_av1',
|
||||||
|
' x264',
|
||||||
|
' x265',
|
||||||
|
' nvenc_h264',
|
||||||
|
' --encoder-preset <string>',
|
||||||
|
' ignored',
|
||||||
|
].join('\n')
|
||||||
|
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
|
||||||
|
})
|
||||||
|
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
|
||||||
|
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
|
||||||
|
'cpu-x264',
|
||||||
|
'cpu-x265',
|
||||||
|
'cpu-av1',
|
||||||
|
'vce',
|
||||||
|
'vce-av1',
|
||||||
|
])
|
||||||
|
expect(leiteBackendsAb([])).toEqual([])
|
||||||
|
})
|
||||||
|
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
|
||||||
|
const liste = [
|
||||||
|
'General/',
|
||||||
|
' Very Fast 2160p60 4K AV1',
|
||||||
|
' AV1 video (up to 2160p60) and AAC stereo audio.',
|
||||||
|
'Hardware/',
|
||||||
|
' H.265 VCN 2160p 4K',
|
||||||
|
'[10:26:05] qsv: not available on this system',
|
||||||
|
].join('\n')
|
||||||
|
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Tests der Audio-CD-Rechnungen und der MusicBrainz-Kennung — die
|
||||||
|
// Referenzzahlen sind die ECHTE AC/DC-„Back in Black"-Antwort vom
|
||||||
|
// 29.08.2026 (test_musicbrainz_cd.py: „abgefragt, nicht erinnert").
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
bloecke,
|
||||||
|
dateiname,
|
||||||
|
dauerText,
|
||||||
|
flacBefehl,
|
||||||
|
msfZuLba,
|
||||||
|
rawReadInfo,
|
||||||
|
sauberName,
|
||||||
|
tocZerlegen,
|
||||||
|
wavKopf,
|
||||||
|
OFFSET_EINHEIT,
|
||||||
|
SEKTOR_BYTES,
|
||||||
|
VORLAUF_FRAMES,
|
||||||
|
} from '../src/kern/rip/audiocd'
|
||||||
|
import { discId, kennungAusSpuren, tagsJeSpur, PLAETZE } from '../src/kern/metadaten/musicbrainz'
|
||||||
|
import { extractGueltigBis, extractKey } from '../src/kern/werkzeuge/schluessel'
|
||||||
|
|
||||||
|
const ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937, 133712, 151660, 170112]
|
||||||
|
const ACDC_LEAD_OUT = 189281
|
||||||
|
const ACDC_KENNUNG = '3KVSIWn_ewv9z0cCizmOJzDWtsQ-'
|
||||||
|
|
||||||
|
describe('MusicBrainz-DiscID', () => {
|
||||||
|
it('DIE Referenz: stimmt mit MusicBrainz überein', () => {
|
||||||
|
expect(discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10)).toBe(ACDC_KENNUNG)
|
||||||
|
})
|
||||||
|
it('der Vorlauf wird wieder HINZUgerechnet (LBA→MSF-Falle)', () => {
|
||||||
|
const spuren = ACDC_VERSAETZE.map((versatz, i) => ({
|
||||||
|
nr: i + 1,
|
||||||
|
start: versatz - VORLAUF_FRAMES,
|
||||||
|
sektoren: (i + 1 < ACDC_VERSAETZE.length ? ACDC_VERSAETZE[i + 1] : ACDC_LEAD_OUT) - versatz,
|
||||||
|
audio: true,
|
||||||
|
dauer: '',
|
||||||
|
}))
|
||||||
|
expect(kennungAusSpuren(spuren)).toBe(ACDC_KENNUNG)
|
||||||
|
})
|
||||||
|
it('MusicBrainz-Alphabet: kein + / =', () => {
|
||||||
|
const kennung = discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10)
|
||||||
|
expect(/[+/=]/.test(kennung)).toBe(false)
|
||||||
|
})
|
||||||
|
it('immer 99 Plätze — und jede Zahl zählt', () => {
|
||||||
|
expect(PLAETZE).toBe(99)
|
||||||
|
expect(discId([150, 20000], 40000, 1, 2)).not.toBe(discId([150, 20001], 40000, 1, 2))
|
||||||
|
expect(discId([], 1000)).toBe('')
|
||||||
|
expect(kennungAusSpuren([])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MSF/LBA und TOC', () => {
|
||||||
|
it('msfZuLba zieht die 150 Vorlauf-Frames ab', () => {
|
||||||
|
expect(msfZuLba(0, 2, 0)).toBe(0)
|
||||||
|
expect(msfZuLba(0, 2, 32)).toBe(32)
|
||||||
|
expect(msfZuLba(1, 0, 0)).toBe(75 * 60 - 150)
|
||||||
|
expect(dauerText(4500)).toBe('1:00')
|
||||||
|
expect(dauerText(75 * 61)).toBe('1:01')
|
||||||
|
})
|
||||||
|
it('tocZerlegen liest Spuren, erkennt Daten-Tracks, braucht den Lead-Out', () => {
|
||||||
|
// CDROM_TOC: 4 Byte Kopf + Einträge à 8 Byte (…, Adr/Control, Nr, …, 0,M,S,F)
|
||||||
|
const toc = Buffer.alloc(4 + 3 * 8)
|
||||||
|
const eintrag = (stelle: number, control: number, nr: number, m: number, s: number, f: number): void => {
|
||||||
|
toc[4 + stelle * 8 + 1] = control
|
||||||
|
toc[4 + stelle * 8 + 2] = nr
|
||||||
|
toc[4 + stelle * 8 + 5] = m
|
||||||
|
toc[4 + stelle * 8 + 6] = s
|
||||||
|
toc[4 + stelle * 8 + 7] = f
|
||||||
|
}
|
||||||
|
eintrag(0, 0x00, 1, 0, 2, 0) // Audio ab LBA 0
|
||||||
|
eintrag(1, 0x04, 2, 1, 2, 0) // DATEN-Track ab LBA 4500
|
||||||
|
eintrag(2, 0x00, 0xaa, 2, 2, 0) // Lead-Out bei LBA 9000
|
||||||
|
const spuren = tocZerlegen(toc)
|
||||||
|
expect(spuren).toHaveLength(2)
|
||||||
|
expect(spuren[0]).toMatchObject({ nr: 1, start: 0, sektoren: 4500, audio: true, dauer: '1:00' })
|
||||||
|
expect(spuren[1]).toMatchObject({ nr: 2, audio: false })
|
||||||
|
})
|
||||||
|
it('ohne Endmarke fehlt der letzten Spur die Länge — sie fällt weg', () => {
|
||||||
|
const toc = Buffer.alloc(4 + 8)
|
||||||
|
toc[4 + 2] = 1
|
||||||
|
toc[4 + 6] = 2
|
||||||
|
expect(tocZerlegen(toc)).toEqual([])
|
||||||
|
expect(tocZerlegen(Buffer.alloc(4))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Lesen und Kodieren', () => {
|
||||||
|
it('bloecke bleibt unter der 64-KB-Treibergrenze', () => {
|
||||||
|
const teile = bloecke(1000, 60)
|
||||||
|
expect(teile[0]).toEqual([1000, 27])
|
||||||
|
expect(teile[1]).toEqual([1027, 27])
|
||||||
|
expect(teile[2]).toEqual([1000 + 54, 6])
|
||||||
|
expect(27 * SEKTOR_BYTES).toBeLessThan(64 * 1024)
|
||||||
|
})
|
||||||
|
it('rawReadInfo: DiskOffset in 2048ern, obwohl der Sektor 2352 hat', () => {
|
||||||
|
const info = rawReadInfo(100, 27)
|
||||||
|
expect(info.readBigInt64LE(0)).toBe(100n * BigInt(OFFSET_EINHEIT))
|
||||||
|
expect(info.readUInt32LE(8)).toBe(27)
|
||||||
|
expect(info.readUInt32LE(12)).toBe(2) // CDDA
|
||||||
|
})
|
||||||
|
it('wavKopf: 44 Byte, RIFF-Größenfelder stimmen', () => {
|
||||||
|
const kopf = wavKopf(1000)
|
||||||
|
expect(kopf.length).toBe(44)
|
||||||
|
expect(kopf.toString('ascii', 0, 4)).toBe('RIFF')
|
||||||
|
expect(kopf.readUInt32LE(4)).toBe(1036)
|
||||||
|
expect(kopf.readUInt32LE(40)).toBe(1000)
|
||||||
|
expect(kopf.readUInt32LE(24)).toBe(44100)
|
||||||
|
})
|
||||||
|
it('sauberName: AC/DC macht keinen Unterordner auf', () => {
|
||||||
|
expect(sauberName('AC/DC - Back in Black')).toBe('ACDC - Back in Black')
|
||||||
|
expect(sauberName('Ende. ')).toBe('Ende')
|
||||||
|
})
|
||||||
|
it('dateiname mit und ohne Titel', () => {
|
||||||
|
expect(dateiname(1, 'Hells Bells')).toBe('01 - Hells Bells')
|
||||||
|
expect(dateiname(2)).toBe('Track 02')
|
||||||
|
})
|
||||||
|
it('flacBefehl trägt Tags einzeln', () => {
|
||||||
|
const befehl = flacBefehl('a.wav', 'b.flac', { TITLE: 'X', LEER: '' })
|
||||||
|
expect(befehl).toContain('--tag')
|
||||||
|
expect(befehl[befehl.indexOf('--tag') + 1]).toBe('TITLE=X')
|
||||||
|
expect(befehl.join(' ')).not.toContain('LEER=')
|
||||||
|
expect(befehl.at(-1)).toBe('a.wav')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tagsJeSpur', () => {
|
||||||
|
it('setzt Künstler mit Bindewörtern zusammen, nimmt nur das Jahr', () => {
|
||||||
|
const tags = tagsJeSpur({
|
||||||
|
releases: [
|
||||||
|
{
|
||||||
|
title: 'Back in Black',
|
||||||
|
date: '1980-07-25',
|
||||||
|
'artist-credit': [{ name: 'AC/DC' }],
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
tracks: [
|
||||||
|
{ position: 1, title: 'Hells Bells', 'artist-credit': [{ name: 'Simon', joinphrase: ' & ' }, { name: 'Garfunkel' }] },
|
||||||
|
{ position: '2', title: 'Shoot to Thrill' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(tags.get(1)?.['ARTIST']).toBe('Simon & Garfunkel')
|
||||||
|
expect(tags.get(2)?.['ARTIST']).toBe('AC/DC')
|
||||||
|
expect(tags.get(1)?.['DATE']).toBe('1980')
|
||||||
|
expect(tags.get(2)?.['TRACKNUMBER']).toBe('2')
|
||||||
|
expect(tagsJeSpur(null).size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Schlüsselkette (§ 9.1)', () => {
|
||||||
|
it('extractKey findet den Beta-Key im Forum-HTML', () => {
|
||||||
|
const html = '<code>T-' + 'x'.repeat(60) + '</code>'
|
||||||
|
expect(extractKey(html)).toBe('T-' + 'x'.repeat(60))
|
||||||
|
expect(extractKey('kein Key hier T-kurz')).toBe('')
|
||||||
|
})
|
||||||
|
it('extractGueltigBis liest das Ablaufdatum (best effort)', () => {
|
||||||
|
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
||||||
|
expect(extractGueltigBis('nichts')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user