Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77ab0302 | ||
|
|
cedab61ddf | ||
|
|
6f230cbd7f |
@@ -52,17 +52,6 @@ jobs:
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
echo "== Node/TypeScript erkannt =="
|
||||
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
|
||||
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
|
||||
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
|
||||
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
|
||||
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
|
||||
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
|
||||
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
|
||||
if [ "$node_major" -lt 22 ]; then
|
||||
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
|
||||
rot=1
|
||||
fi
|
||||
while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
|
||||
@@ -55,6 +55,3 @@ logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
dist/
|
||||
|
||||
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
|
||||
design-entwuerfe/
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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.
|
||||
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,109 +0,0 @@
|
||||
// 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.');
|
||||
@@ -1,114 +0,0 @@
|
||||
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
|
||||
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
|
||||
//
|
||||
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
|
||||
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
|
||||
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
|
||||
// electron-updater ihn per spawn(…, { detached: true }) startet und
|
||||
// `detached` unter Windows KEIN Breakaway setzt.
|
||||
//
|
||||
// Aufbau (drei Läufe, der äußere ruft die inneren):
|
||||
// node beweise/leine-breakaway.js
|
||||
// -> startet zweimal sich selbst als „innen":
|
||||
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
|
||||
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
|
||||
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
|
||||
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
|
||||
// welcher Enkel noch lebt.
|
||||
//
|
||||
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
|
||||
//
|
||||
// Ausführen: node beweise/leine-breakaway.js
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
|
||||
|
||||
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const KILL_ON_JOB_CLOSE = 0x2000
|
||||
const BREAKAWAY_OK = 0x0800
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
|
||||
const SetInformationJobObject = kernel32.func(
|
||||
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
|
||||
)
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
|
||||
)
|
||||
|
||||
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
|
||||
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
|
||||
|
||||
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
|
||||
const job = CreateJobObjectW(null, null)
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
|
||||
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
|
||||
AssignProcessToJobObject(job, GetCurrentProcess())
|
||||
|
||||
const si = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
|
||||
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
|
||||
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
|
||||
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
|
||||
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
|
||||
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
|
||||
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
|
||||
|
||||
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
|
||||
if (!ok) {
|
||||
console.log(`PID=0 FEHLER=${GetLastError()}`)
|
||||
return
|
||||
}
|
||||
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
|
||||
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
|
||||
}
|
||||
|
||||
function lebt(pid) {
|
||||
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
|
||||
return (ergebnis.stdout || '').includes(String(pid))
|
||||
}
|
||||
|
||||
function aussen() {
|
||||
const faelle = [
|
||||
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
|
||||
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
|
||||
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
|
||||
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
|
||||
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
|
||||
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
|
||||
]
|
||||
for (const [name, modus, erwartetLebend] of faelle) {
|
||||
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
|
||||
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
|
||||
// zugeschnappt, falls sie zuschnappt.
|
||||
const nochDa = pid > 0 ? lebt(pid) : false
|
||||
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
|
||||
console.log(
|
||||
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
|
||||
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
|
||||
)
|
||||
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
|
||||
}
|
||||
}
|
||||
|
||||
const modus = process.argv[2] ?? ''
|
||||
if (modus === 'alt') innen(false, false)
|
||||
else if (modus === 'erlaubt-normal') innen(true, false)
|
||||
else if (modus === 'erlaubt-aus') innen(true, true)
|
||||
else aussen()
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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 });
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 224 KiB |
@@ -1507,19 +1507,9 @@ async def storage_targets():
|
||||
"free_gb": frei_gb,
|
||||
})
|
||||
|
||||
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
|
||||
if frei_blaettern():
|
||||
from rippy import betrieb
|
||||
bekannt = {z["path"].rstrip("\\/").lower() for z in ziele}
|
||||
for laufwerk in betrieb.windows_laufwerke():
|
||||
if laufwerk.rstrip("\\/").lower() in bekannt:
|
||||
continue
|
||||
ziele.append({
|
||||
"name": "Laufwerk %s" % laufwerk.rstrip("\\/"),
|
||||
"path": laufwerk,
|
||||
"is_mount": False,
|
||||
"free_gb": platz(laufwerk),
|
||||
})
|
||||
# Der Laufwerksbuchstaben-Block stand hier für die Windows-
|
||||
# Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT-
|
||||
# WINDOWS.md Entscheid 7); im Container gab es hier nie etwas.
|
||||
return ziele
|
||||
|
||||
return await asyncio.to_thread(sammle)
|
||||
@@ -2145,7 +2135,7 @@ async def browse(path: str = ""):
|
||||
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
|
||||
"""
|
||||
if not path.strip():
|
||||
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
|
||||
laufwerke = []
|
||||
if laufwerke:
|
||||
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
|
||||
path = medien_wurzel()
|
||||
@@ -2203,16 +2193,6 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str
|
||||
return "" if oben == pfad else oben
|
||||
|
||||
|
||||
def betrieb_laufwerke() -> list:
|
||||
"""Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers."""
|
||||
from rippy import betrieb
|
||||
try:
|
||||
return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk}
|
||||
for laufwerk in betrieb.windows_laufwerke()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
@@ -77,6 +77,3 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
||||
assert main.medien_wurzel()
|
||||
|
||||
|
||||
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
|
||||
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
|
||||
assert isinstance(main.betrieb_laufwerke(), list)
|
||||
|
||||
@@ -1114,66 +1114,14 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
log_cb=log_cb)
|
||||
|
||||
|
||||
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
|
||||
output_dir: str = None, auswerfen: bool = True) -> dict:
|
||||
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
|
||||
|
||||
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
|
||||
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
|
||||
"""
|
||||
from rippy.drives import windows as win
|
||||
from rippy.rip import audio_cd
|
||||
|
||||
class Leser:
|
||||
toc = staticmethod(win.audio_toc)
|
||||
|
||||
@staticmethod
|
||||
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
|
||||
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
|
||||
fortschritt)
|
||||
|
||||
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
|
||||
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
|
||||
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
|
||||
# Disc, die niemand kennt.
|
||||
from rippy.rip import musicbrainz_cd
|
||||
|
||||
try:
|
||||
spuren = win.audio_toc(device_path)
|
||||
antwort = musicbrainz_cd.abfragen(
|
||||
musicbrainz_cd.kennung_aus_spuren(spuren))
|
||||
except Exception: # noqa: BLE001
|
||||
antwort = None
|
||||
tags = musicbrainz_cd.tags_je_spur(antwort)
|
||||
ordner = musicbrainz_cd.album_ordner(antwort)
|
||||
|
||||
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
|
||||
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||||
werkzeuge.finden("flac"),
|
||||
fortschritt=progress_cb, tags_je_spur=tags)
|
||||
if auswerfen and ergebnis.get("status") == "success":
|
||||
wirf_disc_aus(device_path)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||
auswerfen: bool = True) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||
Auswurf" (abcde macht das selbst per -x)."""
|
||||
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
|
||||
#
|
||||
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
|
||||
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
|
||||
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
|
||||
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
|
||||
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
|
||||
#
|
||||
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
|
||||
# Wochen so im SAVEPOINT.
|
||||
if os.name == "nt":
|
||||
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
|
||||
auswerfen)
|
||||
Auswurf" (abcde macht das selbst per -x).
|
||||
|
||||
Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach
|
||||
Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3) —
|
||||
diese Fassung rippt CDs nur noch im Container, mit abcde."""
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
|
||||
|
||||
## Aufruf
|
||||
|
||||
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
|
||||
|
||||
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
|
||||
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
|
||||
* pyinstaller, pystray, pillow
|
||||
* Das UI muss gebaut sein: cd docker/ui && npm run build
|
||||
|
||||
## Warum die Versions-Ressource nicht optional ist
|
||||
|
||||
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
|
||||
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
|
||||
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
|
||||
und Windows SmartScreen sieht das genauso.
|
||||
|
||||
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
|
||||
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
|
||||
daneben aus dieser Ressource.
|
||||
|
||||
## Was mit hinein muss
|
||||
|
||||
docker/api/ die API-Module (main.py und Nachbarn)
|
||||
docker/ui/dist/ die gebaute Weboberflaeche
|
||||
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
|
||||
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
|
||||
|
||||
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
|
||||
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
|
||||
VERSION_VORLAGE = """
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=({v0}, {v1}, {v2}, 0),
|
||||
prodvers=({v0}, {v1}, {v2}, 0),
|
||||
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable(u'040704b0', [
|
||||
StringStruct(u'CompanyName', u'Rippy'),
|
||||
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
|
||||
StringStruct(u'FileVersion', u'{version}'),
|
||||
StringStruct(u'InternalName', u'Rippy'),
|
||||
StringStruct(u'OriginalFilename', u'Rippy.exe'),
|
||||
StringStruct(u'ProductName', u'Rippy'),
|
||||
StringStruct(u'ProductVersion', u'{version}')])
|
||||
]),
|
||||
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
|
||||
# Windows die Angaben je nach Systemsprache gar nicht an.
|
||||
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||
]
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def versionsdatei(version: str, ordner: str) -> str:
|
||||
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
|
||||
pfad = os.path.join(ordner, "version_info.txt")
|
||||
with open(pfad, "w", encoding="utf-8") as f:
|
||||
f.write(VERSION_VORLAGE.format(
|
||||
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
|
||||
return pfad
|
||||
|
||||
|
||||
def pruefen() -> list:
|
||||
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
|
||||
|
||||
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
|
||||
aussieht wie fertig und es nicht ist.
|
||||
"""
|
||||
maengel = []
|
||||
if sys.platform != "win32":
|
||||
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
|
||||
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
|
||||
maengel.append("docker/api/main.py fehlt.")
|
||||
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
|
||||
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
|
||||
"die App nicht rippen.")
|
||||
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
|
||||
maengel.append(
|
||||
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
|
||||
"Abhilfe: cd docker/ui && npm ci && npm run build")
|
||||
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
if not os.path.isfile(icon):
|
||||
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
|
||||
else:
|
||||
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
|
||||
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
|
||||
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
|
||||
# noch keine Datei, die funktioniert.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, HIER)
|
||||
from icon import fehlende_pflichtgroessen
|
||||
|
||||
fehlt = fehlende_pflichtgroessen(icon)
|
||||
if fehlt:
|
||||
maengel.append(
|
||||
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
|
||||
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||
% (fehlt,))
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
||||
for modul, paket, wofuer in (
|
||||
("pystray", "pystray", "kein Tray-Symbol"),
|
||||
("PIL", "pillow", "kein Tray-Symbol"),
|
||||
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
|
||||
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
|
||||
):
|
||||
try:
|
||||
__import__(modul)
|
||||
except ImportError:
|
||||
maengel.append(
|
||||
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
|
||||
return maengel
|
||||
|
||||
|
||||
def bauen(ausgabe: str, version: str) -> str:
|
||||
arbeit = os.path.join(ausgabe, "_bau")
|
||||
os.makedirs(arbeit, exist_ok=True)
|
||||
|
||||
trenner = ";" # Windows-Trennzeichen fuer --add-data
|
||||
|
||||
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
|
||||
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
|
||||
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
|
||||
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
|
||||
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, HIER)
|
||||
from werkzeuge import paket_ordner_bauen
|
||||
|
||||
werkzeug_ordner = paket_ordner_bauen(ausgabe)
|
||||
if not werkzeug_ordner:
|
||||
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
|
||||
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
|
||||
|
||||
befehl = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--noconfirm", "--clean",
|
||||
"--onefile",
|
||||
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
|
||||
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
|
||||
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
|
||||
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
|
||||
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
|
||||
# Sekunden auf, bis Python so weit ist.
|
||||
#
|
||||
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
|
||||
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
|
||||
"--windowed",
|
||||
"--name", "RippySetup",
|
||||
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
|
||||
"--version-file", versionsdatei(version, arbeit),
|
||||
"--distpath", ausgabe,
|
||||
"--workpath", os.path.join(arbeit, "work"),
|
||||
"--specpath", arbeit,
|
||||
"--paths", os.path.join(REPO, "src"),
|
||||
"--paths", os.path.join(REPO, "docker", "api"),
|
||||
"--paths", os.path.join(REPO, "docker", "worker"),
|
||||
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
|
||||
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
|
||||
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
|
||||
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
|
||||
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
|
||||
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
|
||||
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
|
||||
"--hidden-import", "uvicorn.logging",
|
||||
"--hidden-import", "uvicorn.loops.auto",
|
||||
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||
"--hidden-import", "uvicorn.lifespan.on",
|
||||
"--hidden-import", "rippy.drives.windows",
|
||||
"--hidden-import", "pystray._win32",
|
||||
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
|
||||
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
|
||||
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
|
||||
# genau dorthin, wovon wir gerade weg wollten.
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.winforms",
|
||||
"--hidden-import", "webview.platforms.edgechromium",
|
||||
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
||||
# und beides zieht Ballast nach.
|
||||
"--exclude-module", "psycopg2",
|
||||
"--exclude-module", "tkinter",
|
||||
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
|
||||
]
|
||||
if werkzeug_ordner:
|
||||
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
|
||||
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
|
||||
|
||||
print("PyInstaller wird aufgerufen …\n")
|
||||
ergebnis = subprocess.run(befehl, cwd=REPO)
|
||||
if ergebnis.returncode != 0:
|
||||
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
|
||||
|
||||
exe = os.path.join(ausgabe, "RippySetup.exe")
|
||||
if not os.path.isfile(exe):
|
||||
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
|
||||
return exe
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
|
||||
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
|
||||
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
|
||||
p.add_argument("--nur-pruefen", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
print("Rippy — Windows-Bau")
|
||||
print(" Repo %s" % REPO)
|
||||
print(" Ausgabe %s" % args.ausgabe)
|
||||
print(" Version %s" % args.version)
|
||||
print()
|
||||
|
||||
maengel = pruefen()
|
||||
if maengel:
|
||||
print("Der Bau kann so nicht laufen:")
|
||||
for m in maengel:
|
||||
print(" X %s" % m)
|
||||
return 1
|
||||
print("Alle Voraussetzungen erfuellt.")
|
||||
if args.nur_pruefen:
|
||||
return 0
|
||||
|
||||
os.makedirs(args.ausgabe, exist_ok=True)
|
||||
exe = bauen(args.ausgabe, args.version)
|
||||
|
||||
groesse = os.path.getsize(exe) / 1024**2
|
||||
print()
|
||||
print("Fertig: %s (%.1f MB)" % (exe, groesse))
|
||||
|
||||
arbeit = os.path.join(args.ausgabe, "_bau")
|
||||
shutil.rmtree(arbeit, ignore_errors=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Einstiegspunkt der gebuendelten RippySetup.exe.
|
||||
|
||||
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
|
||||
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
|
||||
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
|
||||
|
||||
## Warum hier trotzdem etwas zur Ausgabe steht
|
||||
|
||||
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
|
||||
KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026:
|
||||
„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
|
||||
|
||||
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
|
||||
schreibt — uvicorn tut das —, wuerde mit
|
||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
|
||||
zwar mitten im Start, ohne dass irgendwo etwas stuende.
|
||||
|
||||
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
|
||||
|
||||
1. In einem Terminal gestartet? -> dort hineinschreiben
|
||||
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
|
||||
def _ausgabe_richten() -> None:
|
||||
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
|
||||
from rippy.platform import winlauf
|
||||
|
||||
if not winlauf.ohne_konsole():
|
||||
return # aus dem Quellcode gestartet
|
||||
if winlauf.an_elternkonsole_haengen():
|
||||
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
|
||||
# sys.stdout trotz Konsole None.
|
||||
try:
|
||||
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
|
||||
buffering=1)
|
||||
sys.stderr = sys.stdout
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
winlauf.ausgabe_umleiten()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
|
||||
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
|
||||
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
_ausgabe_richten()
|
||||
|
||||
from rippy.windows_app import main as rippy_main
|
||||
|
||||
return rippy_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
|
||||
|
||||
## Der Befund (28.08.2026)
|
||||
|
||||
Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
|
||||
Leeres Blatt)."*
|
||||
|
||||
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
|
||||
|
||||
Bilder = 1
|
||||
256x256 32 bit 5657 Bytes PNG
|
||||
|
||||
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
|
||||
die passende Größe aus der Datei:
|
||||
|
||||
Taskleiste, Verknüpfung klein 16x16
|
||||
Desktop, Startmenü 32x32 / 48x48
|
||||
Große Kacheln, Alt-Tab 256x256
|
||||
|
||||
Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt
|
||||
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
|
||||
enthält, passiert das zuverlässig.
|
||||
|
||||
## Was diese Datei tut
|
||||
|
||||
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
|
||||
Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der
|
||||
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||
|
||||
Aufruf (macht der Bau selbst, siehe `build.py`):
|
||||
|
||||
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
|
||||
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
|
||||
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
|
||||
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
|
||||
|
||||
def groessen_in(pfad: str) -> list:
|
||||
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
|
||||
|
||||
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
|
||||
Fehler, den es hier zu verhindern gilt.
|
||||
"""
|
||||
import struct
|
||||
|
||||
with open(pfad, "rb") as f:
|
||||
roh = f.read()
|
||||
if len(roh) < 6:
|
||||
return []
|
||||
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
|
||||
if reserviert != 0 or typ != 1:
|
||||
return []
|
||||
gefunden = []
|
||||
for i in range(anzahl):
|
||||
block = roh[6 + i * 16: 22 + i * 16]
|
||||
if len(block) < 16:
|
||||
break
|
||||
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
|
||||
gefunden.append(breite)
|
||||
return sorted(gefunden)
|
||||
|
||||
|
||||
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
|
||||
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
|
||||
PFLICHTGROESSEN = (16, 32, 48, 256)
|
||||
|
||||
|
||||
def fehlende_pflichtgroessen(pfad: str) -> tuple:
|
||||
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
|
||||
vorhanden = set(groessen_in(pfad))
|
||||
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
|
||||
|
||||
|
||||
def bauen(quelle: str = None, ziel: str = None) -> str:
|
||||
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
|
||||
|
||||
Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt
|
||||
genau zu dem leeren Blatt, das hier abgestellt werden soll.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
quelle = quelle or QUELLE
|
||||
ziel = ziel or quelle
|
||||
|
||||
bild = Image.open(quelle)
|
||||
bild = bild.convert("RGBA")
|
||||
|
||||
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
|
||||
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
|
||||
# Groesse, und aus 16x16 laesst sich kein 256er machen.
|
||||
if hasattr(bild, "size") and bild.size[0] < 256:
|
||||
try:
|
||||
bild = Image.open(quelle)
|
||||
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
|
||||
bild = bild.convert("RGBA")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
ebenen = []
|
||||
for kante in GROESSEN:
|
||||
if kante >= bild.size[0]:
|
||||
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
|
||||
else:
|
||||
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
|
||||
# zwischen einem erkennbaren Zeichen und Pixelbrei.
|
||||
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
|
||||
|
||||
ebenen[-1].save(ziel, format="ICO",
|
||||
sizes=[(k, k) for k in GROESSEN])
|
||||
return ziel
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
|
||||
p.add_argument("--quelle", default=QUELLE)
|
||||
p.add_argument("--ziel", default=None)
|
||||
p.add_argument("--nur-pruefen", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
vorher = groessen_in(args.quelle)
|
||||
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
|
||||
if args.nur_pruefen:
|
||||
return 0 if len(vorher) > 1 else 1
|
||||
|
||||
ziel = bauen(args.quelle, args.ziel)
|
||||
print("Nachher: %s" % groessen_in(ziel))
|
||||
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,25 +0,0 @@
|
||||
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
|
||||
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
|
||||
#
|
||||
# py -3.12 -m venv venv
|
||||
# venv\Scripts\pip install -r docker/api/requirements.txt
|
||||
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
|
||||
# venv\Scripts\python packaging/windows/build.py
|
||||
#
|
||||
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
|
||||
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
|
||||
# ist eine Entscheidung, kein Zufall.
|
||||
|
||||
pyinstaller==6.22.2
|
||||
|
||||
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
|
||||
# zu beenden, ausser ueber den Taskmanager.
|
||||
pystray==0.19.5
|
||||
pillow==12.3.0
|
||||
|
||||
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
|
||||
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
|
||||
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
|
||||
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
|
||||
pywebview==6.2.1
|
||||
pythonnet==3.1.0
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
|
||||
|
||||
## Der Befund (28.08.2026)
|
||||
|
||||
Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
|
||||
Leeres Blatt)."*
|
||||
|
||||
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
|
||||
Windows holt sich fuer jede Stelle die passende Groesse:
|
||||
|
||||
Taskleiste, kleine Verknuepfung 16x16
|
||||
Desktop, Startmenue 32x32 / 48x48
|
||||
Alt-Tab, grosse Kacheln 256x256
|
||||
|
||||
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere
|
||||
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
|
||||
regelmaessig.
|
||||
|
||||
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
|
||||
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
||||
|
||||
|
||||
def _icon_modul():
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, HIER)
|
||||
import icon
|
||||
|
||||
return icon
|
||||
|
||||
|
||||
def _groessen():
|
||||
return _icon_modul().groessen_in(ICON)
|
||||
|
||||
|
||||
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
|
||||
# fordern, die dieser Test nicht prueft — oder umgekehrt.
|
||||
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
|
||||
|
||||
|
||||
def test_icon_ist_da():
|
||||
assert os.path.isfile(ICON), ICON
|
||||
|
||||
|
||||
def test_icon_ist_eine_gueltige_ico():
|
||||
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
|
||||
def test_pflichtgroesse_ist_enthalten(kante):
|
||||
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
|
||||
vorhanden = _groessen()
|
||||
assert kante in vorhanden, (
|
||||
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
|
||||
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
||||
% (kante, kante, vorhanden)
|
||||
)
|
||||
|
||||
|
||||
def test_eine_einzige_groesse_reicht_nie():
|
||||
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
|
||||
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
|
||||
assert len(_groessen()) > 1, (
|
||||
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
|
||||
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
|
||||
"gefuehrt hat."
|
||||
)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
|
||||
|
||||
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
|
||||
|
||||
Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden
|
||||
oder während des Setups separat installiert werden! Ohne das ist das tool
|
||||
NICHT einsatzfähig."*
|
||||
|
||||
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
|
||||
die Lizenz, nicht die Bequemlichkeit:
|
||||
|
||||
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
|
||||
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
|
||||
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
|
||||
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
|
||||
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
|
||||
startet dessen Installer — der Nutzer bezieht sie also weiterhin von
|
||||
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
|
||||
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
|
||||
|
||||
## Warum die Datei nicht im Repo liegt
|
||||
|
||||
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
|
||||
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
|
||||
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket —
|
||||
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
|
||||
|
||||
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||
|
||||
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
|
||||
# verlangt ihn bei jeder Weitergabe.
|
||||
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
|
||||
|
||||
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
|
||||
steht unter der GPL-2. Das bedeutet fuer dich:
|
||||
|
||||
* Du darfst es weitergeben und veraendern.
|
||||
* Der Quelltext ist oeffentlich verfuegbar unter:
|
||||
https://github.com/HandBrake/HandBrake
|
||||
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
|
||||
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
||||
|
||||
Mitgelieferte Fassung: {version}
|
||||
Bezogen von: {url}
|
||||
|
||||
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
|
||||
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
|
||||
installiert.
|
||||
"""
|
||||
|
||||
|
||||
def vendor_ordner(basis: str = None) -> str:
|
||||
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
|
||||
|
||||
|
||||
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
|
||||
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
|
||||
|
||||
Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist
|
||||
leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll
|
||||
möglich bleiben, muss dann aber laut sagen, was fehlt.
|
||||
"""
|
||||
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||
from rippy.tools import beschaffen
|
||||
|
||||
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
|
||||
exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||
|
||||
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
|
||||
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
|
||||
|
||||
melden(" HandBrakeCLI wird geholt …")
|
||||
try:
|
||||
pfad = beschaffen.handbrake_holen(
|
||||
ziel_ordner=ordner,
|
||||
fortschritt=lambda t, anteil=None: melden(" " + t))
|
||||
except Exception as e: # noqa: BLE001
|
||||
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
|
||||
return {"pfad": "", "version": "", "geladen": False}
|
||||
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
|
||||
|
||||
|
||||
def _version_von(exe: str) -> str:
|
||||
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||
from rippy.tools import katalog
|
||||
|
||||
try:
|
||||
return katalog.version_von("handbrake", exe) or ""
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
|
||||
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
|
||||
|
||||
Struktur — genau die, in der `katalog.kandidaten()` sucht:
|
||||
|
||||
tools/handbrake/HandBrakeCLI.exe
|
||||
tools/handbrake/LIZENZ-HandBrake.txt
|
||||
"""
|
||||
hb = handbrake_bereitstellen(ziel_basis, melden)
|
||||
if not hb["pfad"]:
|
||||
return ""
|
||||
|
||||
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
|
||||
ordner = os.path.join(basis, "handbrake")
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
|
||||
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
|
||||
shutil.copy2(hb["pfad"], ziel_exe)
|
||||
|
||||
from rippy.tools import beschaffen
|
||||
|
||||
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
|
||||
url=beschaffen.HANDBRAKE_API))
|
||||
|
||||
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
|
||||
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
|
||||
return basis
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ordner = paket_ordner_bauen()
|
||||
print("Paket-Ordner:", ordner or "(leer)")
|
||||
raise SystemExit(0 if ordner else 1)
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,114 +0,0 @@
|
||||
# Rippy v5 bauen — Anleitung
|
||||
|
||||
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
|
||||
> die Messungen dazu liegen in `beweise/`.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Werkzeug | Version | Anmerkung |
|
||||
|----------|---------|-----------|
|
||||
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
|
||||
| npm | 11 | kommt mit Node 24 |
|
||||
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
|
||||
|
||||
## Einrichten — zwei Schritte, nicht einer
|
||||
|
||||
```
|
||||
npm install
|
||||
node node_modules/electron/install.js
|
||||
```
|
||||
|
||||
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
|
||||
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
|
||||
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
|
||||
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
|
||||
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
|
||||
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
|
||||
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
||||
|
||||
## Die Läufe
|
||||
|
||||
| Befehl | Was er tut |
|
||||
|--------|------------|
|
||||
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
|
||||
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
||||
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
|
||||
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Rippen → Dialog → Sprachen → Folgen → Rip → Warteschlange → Kompression → Ablage → Roh-Dateien. Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
|
||||
|
||||
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||
fasst die echten Einstellungen nicht an.
|
||||
|
||||
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
|
||||
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
|
||||
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
|
||||
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
|
||||
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
|
||||
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
|
||||
`SAVEPOINT.md` mit Ausgabe belegt.
|
||||
|
||||
## Das Setup bauen (W-6)
|
||||
|
||||
```
|
||||
npm run paket
|
||||
```
|
||||
|
||||
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
|
||||
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
|
||||
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
|
||||
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
|
||||
|
||||
⚠ **Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
|
||||
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
|
||||
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
|
||||
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
|
||||
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
|
||||
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
|
||||
lassen, die ⨯-Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
|
||||
(`npx electron-builder --win --config electron-builder.yml
|
||||
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
|
||||
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
|
||||
den gesperrten Ordner löschen, sobald er wieder frei ist.
|
||||
|
||||
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
|
||||
liegen nicht im Git, 74 MB):
|
||||
|
||||
```
|
||||
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
|
||||
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
|
||||
Aufruf ohne eine Zeile Ausgabe)
|
||||
```
|
||||
|
||||
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
|
||||
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
|
||||
in `bau/lizenzen/QUELLEN.md`.
|
||||
|
||||
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
|
||||
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
|
||||
ERSETZEN) — `electron-updater` prüft
|
||||
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
|
||||
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
|
||||
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
|
||||
|
||||
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
|
||||
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
|
||||
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
|
||||
erkennen.
|
||||
|
||||
## Festgelegte Versionen und warum
|
||||
|
||||
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
|
||||
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
|
||||
Release prüfen, ob eine neue Linie ansteht.
|
||||
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
|
||||
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
|
||||
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
|
||||
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
|
||||
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
|
||||
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 5–7) und
|
||||
Vitest 4 (verträgt 6–8), am 30.08.2026 per `npm view` ermittelt.
|
||||
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
|
||||
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
|
||||
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
|
||||
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,192 +0,0 @@
|
||||
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
|
||||
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
|
||||
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
|
||||
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
|
||||
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
|
||||
// Ablage → Roh-Dateien → löschen.
|
||||
//
|
||||
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
|
||||
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
|
||||
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
|
||||
// (Doku »Electron« bei playwright.dev, _electron.launch).
|
||||
//
|
||||
// npm run klick (baut vorher)
|
||||
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
|
||||
import { _electron as electron } from 'playwright'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const profil = join(tmpdir(), 'rippy-klick')
|
||||
const ablage = join(profil, 'ablage')
|
||||
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
|
||||
|
||||
rmSync(profil, { recursive: true, force: true })
|
||||
mkdirSync(profil, { recursive: true })
|
||||
mkdirSync(bilder, { recursive: true })
|
||||
|
||||
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
|
||||
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
|
||||
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
|
||||
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
|
||||
{
|
||||
const db = new DatabaseSync(join(profil, 'rippy.db'))
|
||||
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
|
||||
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
|
||||
setzen.run('setup.done', 'true')
|
||||
setzen.run('ablage', ablage)
|
||||
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
|
||||
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
|
||||
setzen.run('audioSprachen', 'deu,eng')
|
||||
setzen.run('untertitelSprachen', 'deu')
|
||||
setzen.run('rohAufbewahrung', 'behalten')
|
||||
setzen.run('autoAuswurf', 'false')
|
||||
db.close()
|
||||
}
|
||||
|
||||
const app = await electron.launch({
|
||||
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
|
||||
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
|
||||
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' },
|
||||
timeout: 60_000,
|
||||
})
|
||||
const seite = await app.firstWindow()
|
||||
seite.setDefaultTimeout(30_000)
|
||||
seite.on('console', (m) => {
|
||||
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
|
||||
})
|
||||
|
||||
let nr = 0
|
||||
async function bild(name) {
|
||||
nr += 1
|
||||
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
|
||||
await seite.screenshot({ path: pfad, fullPage: true })
|
||||
console.log(`KLICK bild: ${pfad}`)
|
||||
}
|
||||
|
||||
async function schritt(name, tun) {
|
||||
try {
|
||||
await tun()
|
||||
console.log(`KLICK ✓ ${name}`)
|
||||
} catch (fehler) {
|
||||
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
|
||||
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
|
||||
await app.close().catch(() => {})
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = () => seite.locator('div.fixed.inset-0').last()
|
||||
|
||||
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
|
||||
await seite.getByText('LAUFWERK Q:').waitFor()
|
||||
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
|
||||
})
|
||||
await bild('uebersicht')
|
||||
|
||||
await schritt('Rippen → Dialog öffnet sich', async () => {
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
|
||||
await seite.getByText('Was soll gerippt werden?').waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel-Lauf liefert drei Titel', async () => {
|
||||
await dialog().getByText('Titel 0', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 1', { exact: true }).waitFor()
|
||||
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
|
||||
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
|
||||
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
|
||||
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
|
||||
await chips.first().click()
|
||||
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
|
||||
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
|
||||
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
|
||||
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
|
||||
await dialog().getByText(/2 von 3 gewählt/).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
|
||||
const rollen = dialog().locator('select')
|
||||
await rollen.nth(0).selectOption('folge')
|
||||
await rollen.nth(1).selectOption('folge')
|
||||
await dialog().getByText('AB FOLGE').waitFor()
|
||||
await dialog().getByPlaceholder('automatisch').fill('3')
|
||||
})
|
||||
|
||||
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
|
||||
await dialog().getByText(/Serien\\/).waitFor()
|
||||
await dialog().getByText(/Season 01/).waitFor()
|
||||
await dialog().getByText(/S01E03/).waitFor()
|
||||
})
|
||||
await bild('dialog')
|
||||
|
||||
await schritt('Rip starten → Phase RIPPT', async () => {
|
||||
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
|
||||
await seite.getByText(/RIPPT/).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
|
||||
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
|
||||
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
|
||||
})
|
||||
|
||||
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => {
|
||||
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 })
|
||||
})
|
||||
await bild('fertig')
|
||||
|
||||
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
|
||||
const serien = join(ablage, 'Serien')
|
||||
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
|
||||
const gefunden = []
|
||||
const gehen = (o) => {
|
||||
for (const e of readdirSync(o, { withFileTypes: true })) {
|
||||
const p = join(o, e.name)
|
||||
if (e.isDirectory()) gehen(p)
|
||||
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
|
||||
}
|
||||
}
|
||||
gehen(serien)
|
||||
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
|
||||
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
|
||||
throw new Error(`gefunden: ${namen.join(', ')}`)
|
||||
}
|
||||
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
|
||||
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
|
||||
})
|
||||
|
||||
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
|
||||
await seite.getByRole('button', { name: 'Einstellungen' }).click()
|
||||
await seite.locator('#roh').waitFor()
|
||||
await seite.getByText(/Belegung jetzt/).waitFor()
|
||||
await seite.getByText(/Roh: \d/).first().waitFor()
|
||||
})
|
||||
await bild('roh-dateien')
|
||||
|
||||
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
|
||||
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
|
||||
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
||||
})
|
||||
|
||||
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||
await seite.getByText(/Kompression „/).first().waitFor()
|
||||
})
|
||||
await bild('ereignisse')
|
||||
|
||||
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.')
|
||||
await app.close().catch(() => {})
|
||||
process.exit(0)
|
||||
@@ -1,90 +0,0 @@
|
||||
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
|
||||
// HandBrake 1.11 gemessen haben:
|
||||
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
|
||||
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
|
||||
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
|
||||
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert
|
||||
const fs = require('node:fs')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
const wert = (schalter) => {
|
||||
const i = args.indexOf(schalter)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
|
||||
out('')
|
||||
out('Video Options -----------------------------------------------------------------')
|
||||
out('')
|
||||
out(' -e, --encoder <string> Select video encoder:')
|
||||
out(' x264')
|
||||
out(' x265')
|
||||
out(' nvenc_h264')
|
||||
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--preset-list')) {
|
||||
out('General/')
|
||||
out(' Very Fast 1080p30')
|
||||
out(' Fast 1080p30')
|
||||
out(' HQ 1080p30 Surround')
|
||||
out('')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (args.includes('--scan')) {
|
||||
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 1, 100.00 %')
|
||||
out('JSON Title Set: {')
|
||||
out('"MainFeature": 0,')
|
||||
out('"TitleList": [')
|
||||
out('{')
|
||||
out('"AudioList": [')
|
||||
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
|
||||
out('],')
|
||||
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||
out('"Index": 1,')
|
||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||
out('"SubtitleList": [')
|
||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
|
||||
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
|
||||
out(']')
|
||||
out('}')
|
||||
out(']')
|
||||
out('}')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const eingabe = wert('--input') ?? wert('-i')
|
||||
const ausgabe = wert('--output') ?? wert('-o')
|
||||
if (eingabe === undefined || ausgabe === undefined) {
|
||||
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
|
||||
process.exit(1)
|
||||
}
|
||||
out('[12:00:00] hb_init: starting libhb thread')
|
||||
out('Scanning title 1 of 1, preview 10, 100.00 %')
|
||||
let schritt = 0
|
||||
const stufen = [5, 30, 55, 80, 100]
|
||||
const tick = () => {
|
||||
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 300)
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(eingabe, ausgabe)
|
||||
out('[12:00:05] libhb: work result = 0')
|
||||
out('')
|
||||
out('Encode done!')
|
||||
out('HandBrake has exited.')
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
@@ -1,79 +0,0 @@
|
||||
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
|
||||
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
|
||||
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
|
||||
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
|
||||
//
|
||||
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
|
||||
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
|
||||
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
|
||||
//
|
||||
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const befehl = args.find((a) => a === 'info' || a === 'mkv')
|
||||
const out = (zeile) => process.stdout.write(zeile + '\n')
|
||||
|
||||
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
|
||||
|
||||
const TITEL = [
|
||||
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
|
||||
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
|
||||
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
|
||||
]
|
||||
|
||||
if (befehl === 'info') {
|
||||
out(`TCOUNT:${TITEL.length}`)
|
||||
for (const t of TITEL) {
|
||||
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
|
||||
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
|
||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||
if (t.nr < 2) {
|
||||
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},1,3,0,"jpn"`)
|
||||
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
|
||||
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
|
||||
out(`SINFO:${t.nr},2,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},2,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},3,3,0,"deu"`)
|
||||
out(`SINFO:${t.nr},3,4,0,"German"`)
|
||||
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
|
||||
out(`SINFO:${t.nr},4,3,0,"eng"`)
|
||||
out(`SINFO:${t.nr},4,4,0,"English"`)
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (befehl === 'mkv') {
|
||||
const i = args.indexOf('mkv')
|
||||
const nr = Number(args[i + 2])
|
||||
const ziel = args[i + 3]
|
||||
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
|
||||
out('MSG:5010,0,0,"Falscher Aufruf"')
|
||||
process.exit(1)
|
||||
}
|
||||
let schritt = 0
|
||||
const stufen = [10, 35, 60, 85, 100]
|
||||
const tick = () => {
|
||||
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
|
||||
schritt += 1
|
||||
if (schritt < stufen.length) {
|
||||
setTimeout(tick, 250)
|
||||
return
|
||||
}
|
||||
fs.mkdirSync(ziel, { recursive: true })
|
||||
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
|
||||
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
|
||||
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
|
||||
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
|
||||
process.exit(0)
|
||||
}
|
||||
setTimeout(tick, 200)
|
||||
} else {
|
||||
out('MSG:5010,0,0,"Unbekannter Befehl"')
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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.
|
||||
@@ -1,338 +0,0 @@
|
||||
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.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
|
||||
@@ -1,65 +0,0 @@
|
||||
Version 5.4.0
|
||||
|
||||
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
|
||||
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
|
||||
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
|
||||
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
|
||||
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
|
||||
|
||||
Roh-Dateien raeumen sich auf.
|
||||
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
|
||||
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
|
||||
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
|
||||
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
|
||||
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
|
||||
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
|
||||
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
|
||||
|
||||
Sprachen je Titel.
|
||||
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
|
||||
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
|
||||
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
|
||||
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
|
||||
dass alle Sprachen mitkommen.
|
||||
|
||||
Staffel und Folge von Hand.
|
||||
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
|
||||
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
|
||||
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
|
||||
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
|
||||
|
||||
Erkennung v2 und Kino-Banner.
|
||||
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
|
||||
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
|
||||
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
|
||||
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
|
||||
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
|
||||
Hardware?
|
||||
|
||||
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
|
||||
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
|
||||
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
|
||||
ganzen Weg durch das echte Programm.
|
||||
|
||||
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
|
||||
|
||||
Version 5.3.2
|
||||
|
||||
Die Titelliste bleibt jetzt stehen.
|
||||
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
|
||||
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
|
||||
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
|
||||
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
|
||||
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
|
||||
auf derselben Disc gegenseitig aus.
|
||||
|
||||
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
|
||||
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
|
||||
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
|
||||
|
||||
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
|
||||
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
|
||||
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
|
||||
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
|
||||
|
||||
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
|
||||
|
Before Width: | Height: | Size: 486 B |
@@ -1,42 +0,0 @@
|
||||
# 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
|
||||
@@ -1,70 +0,0 @@
|
||||
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
|
||||
// haupt → out/haupt/index.js (Electron Main)
|
||||
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
|
||||
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
|
||||
// bruecke → out/bruecke/index.js (Preload des Fensters)
|
||||
// fenster → out/fenster/ (React)
|
||||
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
|
||||
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
|
||||
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
|
||||
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
|
||||
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
|
||||
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
|
||||
function cspNurImBau(): Plugin {
|
||||
return {
|
||||
name: 'csp-nur-im-bau',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<head>',
|
||||
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
|
||||
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
|
||||
// als data:-URI ein, und die blockte die CSP — als Fehler in der
|
||||
// Konsole, unsichtbar im Fenster.
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/haupt',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/haupt/index.ts'),
|
||||
kern: resolve(__dirname, 'src/kern/index.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/bruecke',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: 'src/fenster',
|
||||
plugins: [react(), tailwindcss(), cspNurImBau()],
|
||||
build: {
|
||||
outDir: 'out/fenster',
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/fenster/index.html') },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.4.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",
|
||||
"klick": "electron-vite build && node bau/klick-smoke.mjs",
|
||||
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
"koffi": "^3.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/instrument-sans": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "44.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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>
|
||||
},
|
||||
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
|
||||
* „schliessen" versteckt nur — Rippy lebt im Tray weiter. */
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
|
||||
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
|
||||
},
|
||||
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
|
||||
updatePruefen(): Promise<unknown> {
|
||||
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
|
||||
},
|
||||
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
|
||||
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
|
||||
},
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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>
|
||||
updatePruefen(): Promise<unknown>
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,374 +0,0 @@
|
||||
// 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 FensterNachricht,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type LaufwerkBericht,
|
||||
type MetaVorschlag,
|
||||
type RipAuftragOptionen,
|
||||
type RipAuftragTitel,
|
||||
type RipStatus,
|
||||
type TitelStand,
|
||||
type VorgaengeStand,
|
||||
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[]>
|
||||
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
|
||||
discTitel: Record<string, TitelStand>
|
||||
/** 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
|
||||
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) — null,
|
||||
* solange der Kern noch nichts geschickt hat. */
|
||||
vorgaenge: VorgaengeStand | null
|
||||
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
|
||||
gesundheit: Record<string, LaufwerkBericht>
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 30
|
||||
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
vorgaenge: null,
|
||||
gesundheit: {},
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function kopie(): Verbindung {
|
||||
return {
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
gesundheit: { ...stand.gesundheit },
|
||||
meldungen: [...stand.meldungen],
|
||||
}
|
||||
}
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) h(kopie())
|
||||
}
|
||||
|
||||
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 'kern-hinweis':
|
||||
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': {
|
||||
const vorher = stand.discInfos[nachricht.info.id]
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
|
||||
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
|
||||
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
|
||||
if (neuErkannt) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
for (const h of nachricht.info.hinweise) {
|
||||
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'disc-titel':
|
||||
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
|
||||
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
|
||||
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 'vorgaenge':
|
||||
stand.vorgaenge = nachricht.stand
|
||||
break
|
||||
case 'kompression-ergebnis':
|
||||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerk-gesundheit':
|
||||
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
|
||||
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
|
||||
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.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
|
||||
}
|
||||
case 'installer-starten':
|
||||
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
|
||||
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()
|
||||
senden({ art: 'ping', zeit: Date.now() })
|
||||
senden({ art: 'einstellungen-laden' })
|
||||
senden({ art: 'werkzeuge-laden' })
|
||||
senden({ art: 'bibliothek-laden' })
|
||||
senden({ art: 'vorgaenge-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(kopie())
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function senden(nachricht: FensterNachricht): void {
|
||||
port?.postMessage(nachricht)
|
||||
}
|
||||
|
||||
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
|
||||
function anKern(nachricht: FensterNachricht, 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}`)
|
||||
}
|
||||
|
||||
/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
||||
export function titelLesen(id: string): void {
|
||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||
}
|
||||
|
||||
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
||||
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
||||
export function ripUeberspringen(id: string): void {
|
||||
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
senden({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
senden({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
senden({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||
}
|
||||
|
||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||
export function vorgaengeLaden(): void {
|
||||
senden({ art: 'vorgaenge-laden' })
|
||||
}
|
||||
|
||||
export function vorgangRohLoeschen(vorgangId: string): void {
|
||||
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
|
||||
}
|
||||
|
||||
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
|
||||
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
|
||||
}
|
||||
|
||||
export function kompressionAbbrechen(vorgangId: string): void {
|
||||
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
|
||||
}
|
||||
|
||||
export function rohAufraeumen(): void {
|
||||
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
|
||||
}
|
||||
|
||||
export function fremdenOrdnerLoeschen(ordner: string): void {
|
||||
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
|
||||
}
|
||||
|
||||
export function laufwerkPruefen(id: string): void {
|
||||
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
|
||||
Windows-Rahmen mehr (frame:false) — gezogen wird an der Kopfzeile.
|
||||
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
|
||||
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
|
||||
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
|
||||
.ziehbar {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.nicht-ziehbar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
@@ -1,74 +0,0 @@
|
||||
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
|
||||
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
|
||||
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
|
||||
// baut auf diesen Namen ihre echten Ordner.
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export function staffelOrdnerName(staffel: number): string {
|
||||
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
|
||||
const s = String(Math.trunc(staffel)).padStart(2, '0')
|
||||
const e = String(Math.trunc(folge)).padStart(2, '0')
|
||||
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
|
||||
}
|
||||
|
||||
export interface VorschauEingabe {
|
||||
ablage: string
|
||||
alsSerie: boolean
|
||||
titel: string
|
||||
jahr: number | null
|
||||
staffel: number | null
|
||||
ersteFolge: number | null
|
||||
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
|
||||
hauptTitel: number
|
||||
extras: number
|
||||
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
|
||||
vorgangsName: string
|
||||
}
|
||||
|
||||
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
|
||||
* weil die Vorschau eine Windows-Ablage beschreibt. */
|
||||
export function zielVorschau(e: VorschauEingabe): [string, string] {
|
||||
const wurzel = e.ablage.replace(/[\\/]+$/, '')
|
||||
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
|
||||
if (e.alsSerie) {
|
||||
const staffel = e.staffel ?? 1
|
||||
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
|
||||
const n = Math.max(1, e.hauptTitel)
|
||||
let inhalt: string
|
||||
if (e.ersteFolge !== null && e.ersteFolge > 0) {
|
||||
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
|
||||
inhalt =
|
||||
n === 1
|
||||
? erste
|
||||
: `${erste} … ${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
|
||||
} else {
|
||||
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
|
||||
}
|
||||
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
|
||||
return [ordner, inhalt]
|
||||
}
|
||||
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
|
||||
const teile: string[] = []
|
||||
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
|
||||
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
|
||||
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
// 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)
|
||||
//
|
||||
// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union
|
||||
// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die
|
||||
// Nachricht, und niemand erfährt es.
|
||||
|
||||
/** 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
|
||||
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
|
||||
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
|
||||
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||
titelNr?: number
|
||||
titelGesamt?: number
|
||||
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||
haengt?: { offset: string; wiederholungen: number } | null
|
||||
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) — die Kompression läuft
|
||||
* danach unter dieser Kennung in der Warteschlange weiter. */
|
||||
vorgangId?: string
|
||||
}
|
||||
|
||||
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||||
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||||
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||||
* EINE Definition für Kern und Fenster. */
|
||||
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||||
|
||||
/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat
|
||||
* (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren
|
||||
* dieser Sprache es gibt (Stereo + 5.1 = 2). */
|
||||
export interface SprachEintrag {
|
||||
lang: string
|
||||
sprache: string
|
||||
spuren: number
|
||||
}
|
||||
|
||||
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||||
export interface RipAuftragTitel {
|
||||
nr: number
|
||||
rolle: TitelRolle
|
||||
dauerS: number
|
||||
/** Größe laut Info-Lauf — gewichtet den durchgehenden Fortschrittsbalken
|
||||
* (5.4.0). Fehlt: alle Titel zählen gleich. */
|
||||
groesseBytes?: number
|
||||
/** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche
|
||||
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
|
||||
audioSprachen?: string[]
|
||||
untertitelSprachen?: string[]
|
||||
}
|
||||
|
||||
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
|
||||
* Staffel und erste Folge von Hand — Rippy rät bewusst nicht, aber der
|
||||
* Nutzer weiß, dass auf Disc 2 die Folgen 3–4 sind. */
|
||||
export interface RipAuftragOptionen {
|
||||
/** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */
|
||||
staffel?: number | null
|
||||
/** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend:
|
||||
* Zuordnung über die TMDb-Laufzeiten wie bisher. */
|
||||
ersteFolge?: number | null
|
||||
}
|
||||
|
||||
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||
export interface DiscTitelVorschlag {
|
||||
nr: number
|
||||
dauerS: number
|
||||
groesseBytes: number
|
||||
kapitel: number
|
||||
/** Von Rippy vorausgewählt. */
|
||||
gewaehlt: boolean
|
||||
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||
rolle: TitelRolle
|
||||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||
grund: string
|
||||
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||
}
|
||||
|
||||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||||
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||
export interface TitelStand {
|
||||
id: string
|
||||
laeuft: boolean
|
||||
titel: DiscTitelVorschlag[]
|
||||
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||
einordnung: string
|
||||
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */
|
||||
export interface Darsteller {
|
||||
name: string
|
||||
rolle: string
|
||||
/** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */
|
||||
bildPfad: string
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||||
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||||
folgen: number
|
||||
/** 'movie' | 'tv' | '' — was die Zuordnung sagt. */
|
||||
typ: string
|
||||
// ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ──
|
||||
/** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */
|
||||
logoPfad: string
|
||||
tagline: string
|
||||
/** TMDb vote_average (0 = keine). */
|
||||
bewertung: number
|
||||
/** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */
|
||||
fsk: string
|
||||
laufzeitMinuten: number
|
||||
genres: string[]
|
||||
besetzung: Darsteller[]
|
||||
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat („Laufzeit
|
||||
* bestätigt: 124 min"), Klartext-Zeilen. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
||||
/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die
|
||||
* Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER
|
||||
* verschoben). */
|
||||
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
|
||||
|
||||
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen → Roh-Dateien,
|
||||
* Übersicht → Kompression). */
|
||||
export interface VorgangEintrag {
|
||||
id: string
|
||||
laufwerk: string
|
||||
typ: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
phase: VorgangPhase
|
||||
begonnen: string
|
||||
beendet: string | null
|
||||
rohOrdner: string
|
||||
/** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */
|
||||
rohBytes: number
|
||||
rohGeloescht: boolean
|
||||
zielOrdner: string
|
||||
fertigBytes: number
|
||||
komprimiert: boolean
|
||||
preset: string
|
||||
fehler: string
|
||||
titelZahl: number
|
||||
/** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */
|
||||
neuKomprimierbar: boolean
|
||||
/** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */
|
||||
faelligAm: string
|
||||
}
|
||||
|
||||
export interface KompressionStand {
|
||||
vorgangId: string
|
||||
titel: string
|
||||
/** 0–100; -1 = keine Angabe. */
|
||||
prozent: number
|
||||
text: string
|
||||
phase: VorgangPhase
|
||||
}
|
||||
|
||||
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
|
||||
* 5.4.0 oder von Hand hineingelegt) — nur von Hand löschbar. */
|
||||
export interface FremderOrdner {
|
||||
ordner: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface VorgaengeStand {
|
||||
vorgaenge: VorgangEintrag[]
|
||||
/** Summe aller Roh-Ordner, die noch da sind. */
|
||||
rohGesamtBytes: number
|
||||
fremde: FremderOrdner[]
|
||||
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
|
||||
regel: string
|
||||
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[] }
|
||||
}
|
||||
|
||||
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
|
||||
export interface LaufwerkBericht {
|
||||
/** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */
|
||||
urteil: string
|
||||
text: string
|
||||
fehlerhafteBloecke: number
|
||||
controllerfehler: number
|
||||
sonstige: number
|
||||
/** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */
|
||||
letzte: string[]
|
||||
/** Betrachteter Zeitraum in Stunden. */
|
||||
stunden: number
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
/** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): „Roh-
|
||||
* Ordner von X gelöscht", „Kompression eingereiht" — Auskunft, kein Alarm. */
|
||||
| { art: 'kern-hinweis'; 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: 'disc-titel'; stand: TitelStand }
|
||||
| { 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
|
||||
}
|
||||
}
|
||||
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
||||
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
||||
| { art: 'vorgaenge'; stand: VorgaengeStand }
|
||||
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
|
||||
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
||||
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||
* sehr sehr lange"). */
|
||||
| { art: 'titel-lesen'; id: string }
|
||||
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||
| { art: 'rip-ueberspringen'; 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' }
|
||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||
| { art: 'vorgaenge-laden' }
|
||||
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
||||
| { art: 'vorgang-roh-loeschen'; vorgangId: string }
|
||||
/** „Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel
|
||||
* kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */
|
||||
| { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string }
|
||||
/** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */
|
||||
| { art: 'kompression-abbrechen'; vorgangId: string }
|
||||
/** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */
|
||||
| { art: 'roh-aufraeumen' }
|
||||
/** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */
|
||||
| { art: 'fremden-ordner-loeschen'; ordner: string }
|
||||
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
|
||||
| { art: 'laufwerk-pruefen'; id: string }
|
||||
|
||||
/** 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). */
|
||||
update: UpdateStand
|
||||
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
|
||||
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
|
||||
fensterMaximiert: boolean
|
||||
}
|
||||
|
||||
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||||
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||||
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||||
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||||
neueVersion: string
|
||||
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||||
neuerungen: string
|
||||
/** Geladen und wartet auf den Neustart. */
|
||||
bereit: boolean
|
||||
/** Eine Prüfung oder ein Download läuft gerade. */
|
||||
laeuft: boolean
|
||||
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||||
fortschritt: number
|
||||
}
|
||||
|
||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||
return typeof wert === 'object' && wert !== null
|
||||
}
|
||||
|
||||
function istText(wert: unknown): wert is string {
|
||||
return typeof wert === 'string'
|
||||
}
|
||||
|
||||
function istKennung(wert: unknown): wert is string {
|
||||
return typeof wert === 'string' && wert.length > 0
|
||||
}
|
||||
|
||||
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':
|
||||
case 'kern-hinweis':
|
||||
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 'disc-titel':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).titel)
|
||||
)
|
||||
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)
|
||||
case 'vorgaenge':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
|
||||
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
|
||||
istObjekt((wert.stand as Record<string, unknown>).kompression)
|
||||
)
|
||||
case 'kompression-ergebnis':
|
||||
return (
|
||||
istKennung(wert.vorgangId) &&
|
||||
typeof wert.titel === 'string' &&
|
||||
typeof wert.ok === 'boolean' &&
|
||||
typeof wert.text === 'string'
|
||||
)
|
||||
case 'laufwerk-gesundheit':
|
||||
return (
|
||||
istKennung(wert.id) &&
|
||||
istObjekt(wert.bericht) &&
|
||||
typeof (wert.bericht as Record<string, unknown>).urteil === 'string' &&
|
||||
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
|
||||
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt — die Sprachlisten
|
||||
* sind optional, aber wenn sie da sind, müssen es Textlisten sein. */
|
||||
export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false
|
||||
if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false
|
||||
if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false
|
||||
for (const feld of ['audioSprachen', 'untertitelSprachen']) {
|
||||
const liste = wert[feld]
|
||||
if (liste === undefined) continue
|
||||
if (!Array.isArray(liste) || !liste.every(istText)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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 === 'rip-start') {
|
||||
if (!istKennung(wert.id)) return false
|
||||
if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) {
|
||||
return false
|
||||
}
|
||||
if (wert.optionen !== undefined) {
|
||||
if (!istObjekt(wert.optionen)) return false
|
||||
const o = wert.optionen
|
||||
const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number'
|
||||
if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (
|
||||
wert.art === 'auswerfen' ||
|
||||
wert.art === 'titel-lesen' ||
|
||||
wert.art === 'rip-abbruch' ||
|
||||
wert.art === 'rip-ueberspringen' ||
|
||||
wert.art === 'laufwerk-pruefen'
|
||||
) {
|
||||
return istKennung(wert.id)
|
||||
}
|
||||
if (wert.art === 'einstellung-setzen') {
|
||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||
}
|
||||
if (wert.art === 'metadaten-suchen') {
|
||||
return istKennung(wert.id) && typeof wert.text === 'string'
|
||||
}
|
||||
if (wert.art === 'zuordnung-setzen') {
|
||||
return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag)
|
||||
}
|
||||
if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') {
|
||||
return istKennung(wert.vorgangId)
|
||||
}
|
||||
if (wert.art === 'vorgang-neu-komprimieren') {
|
||||
return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string')
|
||||
}
|
||||
if (wert.art === 'fremden-ordner-loeschen') {
|
||||
return istKennung(wert.ordner)
|
||||
}
|
||||
return (
|
||||
wert.art === 'einstellungen-laden' ||
|
||||
wert.art === 'werkzeuge-laden' ||
|
||||
wert.art === 'bibliothek-laden' ||
|
||||
wert.art === 'makemkv-update' ||
|
||||
wert.art === 'schluessel-pruefen' ||
|
||||
wert.art === 'vorgaenge-laden' ||
|
||||
wert.art === 'roh-aufraeumen'
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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'
|
||||
import { fensterrandEntfernen } from './fensterrand'
|
||||
|
||||
export function fensterErstellen(): BrowserWindow {
|
||||
const fenster = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 860,
|
||||
minHeight: 560,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
|
||||
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
|
||||
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
|
||||
//
|
||||
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
|
||||
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
|
||||
// Standard true) — die Kanten lassen sich also weiter ziehen, und
|
||||
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
|
||||
frame: false,
|
||||
// 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.
|
||||
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
|
||||
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
|
||||
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
|
||||
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
|
||||
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Den Windows-11-Fensterrand loswerden.
|
||||
//
|
||||
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
|
||||
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
|
||||
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
|
||||
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
|
||||
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
|
||||
// Fensterinhalts.
|
||||
//
|
||||
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
|
||||
// Windows 11 Build 22000):
|
||||
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
|
||||
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
|
||||
//
|
||||
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
|
||||
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
|
||||
// gibt es diesen Rand schlicht nicht.
|
||||
//
|
||||
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
|
||||
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
|
||||
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
|
||||
// Haupt liegen, weil nur dort das Fensterhandle existiert.
|
||||
import koffi from 'koffi'
|
||||
|
||||
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
|
||||
const DWMWA_BORDER_COLOR = 34
|
||||
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
|
||||
const DWMWA_COLOR_NONE = 0xfffffffe
|
||||
const DWMWCP_DONOTROUND = 1
|
||||
|
||||
export interface RandErgebnis {
|
||||
ok: boolean
|
||||
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab —
|
||||
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
|
||||
* und Rippy sich nicht fremder anfühlen soll, als es muss.
|
||||
*
|
||||
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
|
||||
*/
|
||||
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
|
||||
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
|
||||
try {
|
||||
const dwmapi = koffi.load('dwmapi.dll')
|
||||
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
|
||||
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
|
||||
const DwmSetWindowAttribute = dwmapi.func(
|
||||
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
|
||||
)
|
||||
const hwnd = handle.readBigUInt64LE(0)
|
||||
|
||||
const farbe = Buffer.alloc(4)
|
||||
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
|
||||
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
|
||||
|
||||
let eckenErgebnis = 0
|
||||
if (eckenKantig) {
|
||||
const ecken = Buffer.alloc(4)
|
||||
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
|
||||
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
|
||||
}
|
||||
|
||||
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
|
||||
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
|
||||
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
|
||||
return {
|
||||
ok: false,
|
||||
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
|
||||
}
|
||||
}
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
// 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, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||
|
||||
const smokeModus = process.argv.includes('--smoke')
|
||||
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
|
||||
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
|
||||
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
|
||||
const klickSmoke = process.argv.includes('--klick-smoke')
|
||||
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
|
||||
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
|
||||
const ohneNebenwirkungen = smokeModus || klickSmoke
|
||||
|
||||
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||
if (smokeModus) {
|
||||
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
||||
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
|
||||
app.setPath('userData', profilArg.slice('--profil='.length))
|
||||
}
|
||||
|
||||
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
|
||||
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
|
||||
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
|
||||
// Rippy nicht und wird von ihm nicht gestört.
|
||||
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 && !ohneNebenwirkungen) {
|
||||
updateGestartet = true
|
||||
updateEinrichten(
|
||||
nachricht.werte['updateUrl'] ?? '',
|
||||
() => aktiveVorgaenge.size > 0,
|
||||
(text) => toast('Rippy-Update', text),
|
||||
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
|
||||
// vorher lag sie still im Haupt und war erst beim nächsten
|
||||
// Laden zu sehen (Commander-Fund 01.09.2026).
|
||||
() => statusSenden(),
|
||||
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
|
||||
// der close-Horcher das Fenster nicht bloß versteckt.
|
||||
() => beenden(),
|
||||
)
|
||||
}
|
||||
} else if (nachricht.art === 'installer-starten') {
|
||||
void installerStarten(nachricht.pfad)
|
||||
} else if (nachricht.art === 'vorgaenge') {
|
||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
||||
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
||||
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
||||
// 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,
|
||||
)
|
||||
|
||||
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
|
||||
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
||||
const KOMPRESSION = 'kompression'
|
||||
let kompressionStand: { titel: string; prozent: number } | null = null
|
||||
|
||||
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
||||
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
||||
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
|
||||
else aktiveVorgaenge.add(KOMPRESSION)
|
||||
anzeigeAktualisieren()
|
||||
}
|
||||
|
||||
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
|
||||
* die Kompression — sonst nichts. */
|
||||
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
||||
function anzeigeAktualisieren(): void {
|
||||
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
|
||||
if (fenster !== null && !fenster.isDestroyed()) {
|
||||
if (rip !== null && rip.prozent >= 0) {
|
||||
fenster.setProgressBar(rip.prozent / 100)
|
||||
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
|
||||
fenster.setProgressBar(1, { mode: 'error' })
|
||||
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
|
||||
fenster.setProgressBar(kompressionStand.prozent / 100)
|
||||
} else {
|
||||
fenster.setProgressBar(-1)
|
||||
}
|
||||
}
|
||||
if (rip !== null) {
|
||||
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
|
||||
} else if (kompressionStand !== null) {
|
||||
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
|
||||
} else {
|
||||
trayHinweis('')
|
||||
}
|
||||
}
|
||||
|
||||
// § 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)
|
||||
}
|
||||
letzterRip = status
|
||||
anzeigeAktualisieren()
|
||||
if (ohneNebenwirkungen) return
|
||||
if (status.phase === 'fertig') {
|
||||
toast('Rippy: Rip 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(),
|
||||
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
|
||||
// Fenster gerade maximiert ist.
|
||||
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
|
||||
}
|
||||
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()
|
||||
})
|
||||
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
|
||||
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
|
||||
// maximiert wird, nicht nur über unseren Knopf.
|
||||
fenster.on('maximize', () => statusSenden())
|
||||
fenster.on('unmaximize', () => statusSenden())
|
||||
// § 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()
|
||||
}
|
||||
})
|
||||
|
||||
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
|
||||
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
|
||||
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
|
||||
if (fenster === null || fenster.isDestroyed()) return false
|
||||
if (was === 'minimieren') {
|
||||
fenster.minimize()
|
||||
} else if (was === 'maximieren') {
|
||||
if (fenster.isMaximized()) fenster.unmaximize()
|
||||
else fenster.maximize()
|
||||
} else if (was === 'schliessen') {
|
||||
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
|
||||
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
|
||||
// ein laufender Rip darf davon nichts merken.
|
||||
fenster.close()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return fenster.isDestroyed() ? false : fenster.isMaximized()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
|
||||
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
|
||||
ipcMain.handle('update-pruefen', () => {
|
||||
updatePruefenJetzt()
|
||||
return updateStand()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
|
||||
// statt still nichts zu tun (R4).
|
||||
ipcMain.handle('update-installieren', () => {
|
||||
const urteil = updateInstallierenJetzt()
|
||||
if (!urteil.ok) statusSenden()
|
||||
return urteil
|
||||
})
|
||||
|
||||
// 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 (!ohneNebenwirkungen) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
|
||||
// die Ampel das auf Linux prüfen kann (BAUEN.md).
|
||||
//
|
||||
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
|
||||
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
|
||||
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
|
||||
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
|
||||
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
|
||||
|
||||
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
|
||||
export function argumentQuoten(arg: string): string {
|
||||
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
|
||||
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
|
||||
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
|
||||
|
||||
let ergebnis = '"'
|
||||
let rueckstriche = 0
|
||||
for (const zeichen of arg) {
|
||||
if (zeichen === '\\') {
|
||||
rueckstriche += 1
|
||||
continue
|
||||
}
|
||||
if (zeichen === '"') {
|
||||
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
|
||||
// Anführungszeichen selbst bekommt einen eigenen davor.
|
||||
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
|
||||
rueckstriche = 0
|
||||
continue
|
||||
}
|
||||
ergebnis += '\\'.repeat(rueckstriche) + zeichen
|
||||
rueckstriche = 0
|
||||
}
|
||||
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
|
||||
// davor zählen deshalb ebenfalls doppelt.
|
||||
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
|
||||
}
|
||||
|
||||
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
|
||||
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
|
||||
return [programm, ...argumente].map(argumentQuoten).join(' ')
|
||||
}
|
||||
|
||||
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
|
||||
* danach wieder startet. Gemessen an der installierten Bibliothek
|
||||
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
|
||||
* `/S` für „still", `--force-run` für „danach starten". Genau diese drei
|
||||
* benutzt electron-updater selbst — wir starten den Installer nur auf
|
||||
* einem anderen Weg (außerhalb der Prozess-Leine). */
|
||||
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
|
||||
@@ -1,186 +0,0 @@
|
||||
// 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'
|
||||
import { kommandozeileBauen } from './kommandozeile'
|
||||
|
||||
// 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
|
||||
|
||||
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
|
||||
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
|
||||
// denn, sie verlangen das Ausklinken beim Anlegen selbst
|
||||
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
|
||||
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
|
||||
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
|
||||
//
|
||||
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
|
||||
// electron-updater wird per `spawn(…, { detached: true })` gestartet
|
||||
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
|
||||
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
|
||||
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
|
||||
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
|
||||
// beendet aber startet NICHT neu in der neuen version."
|
||||
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
||||
|
||||
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
|
||||
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
|
||||
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
|
||||
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
|
||||
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
|
||||
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
|
||||
// zeigte deshalb erst ein falsches Negativ.
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
|
||||
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
|
||||
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
// 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 | JOB_OBJECT_LIMIT_BREAKAWAY_OK, 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
|
||||
*
|
||||
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
|
||||
* Kind, das Rippys eigenen Tod überleben MUSS — er installiert ja gerade
|
||||
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
|
||||
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
|
||||
*
|
||||
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
|
||||
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
|
||||
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
|
||||
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
|
||||
* `update-info.json` steht `isAdminRightsRequired: false`) — er liefe also
|
||||
* wieder als Kind und stürbe wieder mit.
|
||||
*/
|
||||
export function ausserhalbDerLeineStarten(
|
||||
programm: string,
|
||||
argumente: readonly string[],
|
||||
): { ok: boolean; fehler: string } {
|
||||
try {
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
|
||||
)
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
|
||||
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
|
||||
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
|
||||
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
|
||||
|
||||
const gestartet = CreateProcessW(
|
||||
programm,
|
||||
zeile,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
|
||||
null,
|
||||
null,
|
||||
startupInfo,
|
||||
prozessInfo,
|
||||
)
|
||||
if (!gestartet) {
|
||||
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
|
||||
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
|
||||
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
|
||||
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
|
||||
}
|
||||
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
|
||||
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
|
||||
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
|
||||
// ohne Wirkung.
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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).',
|
||||
}
|
||||
}
|
||||
|
||||
/** So viel Änderungstext zeigt das Fenster höchstens — der Rest wird
|
||||
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
|
||||
export const NEUERUNGEN_HOECHSTENS = 4000
|
||||
|
||||
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
|
||||
*
|
||||
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
|
||||
* `string | Array<ReleaseNoteInfo> | null` — bei uns ein String
|
||||
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
|
||||
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
|
||||
* ankommen können, sonst steht im Fenster „[object Object]".
|
||||
*
|
||||
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
|
||||
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
|
||||
* weitergereicht, was wie Auszeichnung aussieht. */
|
||||
export function neuerungenText(roh: unknown): string {
|
||||
const saubern = (t: string): string =>
|
||||
t
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.split('\n')
|
||||
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
if (Array.isArray(roh)) {
|
||||
const teile: string[] = []
|
||||
for (const eintrag of roh) {
|
||||
if (eintrag === null || typeof eintrag !== 'object') continue
|
||||
const e = eintrag as { version?: unknown; note?: unknown }
|
||||
const version = typeof e.version === 'string' ? e.version : ''
|
||||
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
|
||||
if (notiz.length === 0) continue
|
||||
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
|
||||
}
|
||||
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
// 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 prüfen, 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).
|
||||
//
|
||||
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
|
||||
// Knopf ‚Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
|
||||
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
|
||||
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
|
||||
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
|
||||
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
|
||||
import { app } from 'electron'
|
||||
import electronUpdater from 'electron-updater'
|
||||
import { INSTALLER_ARGUMENTE } from './kommandozeile'
|
||||
import { ausserhalbDerLeineStarten } from './leine'
|
||||
import { neuerungenText, updateUrlPruefen } from './update-regeln'
|
||||
|
||||
export {
|
||||
NEUERUNGEN_HOECHSTENS,
|
||||
STANDARD_UPDATE_URL,
|
||||
neuerungenText,
|
||||
updateUrlPruefen,
|
||||
} from './update-regeln'
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
|
||||
export type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
import type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
|
||||
const ANFANG: UpdateStand = {
|
||||
letzteErfolgreichePruefung: '',
|
||||
text: 'noch nicht geprüft',
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
}
|
||||
|
||||
let stand: UpdateStand = { ...ANFANG }
|
||||
let aenderungMelden: () => void = () => {
|
||||
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
|
||||
}
|
||||
let ripLaeuftJetzt: () => boolean = () => false
|
||||
let scharf = false
|
||||
/** Der geladene Installer — leer, solange nichts bereitliegt. */
|
||||
let installerPfad = ''
|
||||
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
|
||||
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
|
||||
let installerGestartet = false
|
||||
let rippyBeenden: () => void = () => app.quit()
|
||||
|
||||
export function updateStand(): UpdateStand {
|
||||
return { ...stand }
|
||||
}
|
||||
|
||||
/** Stand ändern UND das Fenster benachrichtigen — nie das eine ohne das
|
||||
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
|
||||
* (der 26.07.-Fund „ein still scheiternder Hintergrund-Prozess"). */
|
||||
function setzen(teil: Partial<UpdateStand>): void {
|
||||
stand = { ...stand, ...teil }
|
||||
aenderungMelden()
|
||||
}
|
||||
|
||||
export function updateEinrichten(
|
||||
eigeneUrl: string,
|
||||
ripLaeuft: () => boolean,
|
||||
melden: (text: string) => void,
|
||||
beiAenderung: () => void = () => {
|
||||
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
|
||||
},
|
||||
beenden: () => void = () => app.quit(),
|
||||
): void {
|
||||
aenderungMelden = beiAenderung
|
||||
ripLaeuftJetzt = ripLaeuft
|
||||
rippyBeenden = beenden
|
||||
if (!app.isPackaged) {
|
||||
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
|
||||
return
|
||||
}
|
||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||
if (!pruefung.ok) {
|
||||
setzen({ text: pruefung.grund })
|
||||
melden(pruefung.grund)
|
||||
return
|
||||
}
|
||||
if (eigeneUrl.trim().length > 0) {
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||
}
|
||||
autoUpdater.autoDownload = false
|
||||
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
|
||||
// Objekt bezahlt): Sein eigener Weg startet den Installer per
|
||||
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
|
||||
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
|
||||
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
|
||||
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
scharf = true
|
||||
|
||||
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
|
||||
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
|
||||
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
|
||||
// diese Zeile zurückkommt.
|
||||
app.on('will-quit', () => {
|
||||
if (!stand.bereit || ripLaeuftJetzt()) return
|
||||
installerAnwerfen()
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
const neuerungen = neuerungenText(info.releaseNotes)
|
||||
if (ripLaeuft()) {
|
||||
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||
// tägliche Takt versucht es wieder.
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
return
|
||||
}
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: true,
|
||||
fortschritt: 0,
|
||||
text: `Version ${info.version} wird geladen …`,
|
||||
})
|
||||
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
|
||||
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
|
||||
void autoUpdater
|
||||
.downloadUpdate()
|
||||
.then((pfade: string[]) => {
|
||||
// downloadUpdate liefert die abgelegten Dateien zurück
|
||||
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
|
||||
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
|
||||
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
|
||||
// ebenfalls heraus, ohne erneut zu laden.
|
||||
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
|
||||
if (exe.length > 0) installerPfad = exe
|
||||
})
|
||||
.catch((fehler: unknown) => {
|
||||
setzen({
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
autoUpdater.on('download-progress', (fortschritt) => {
|
||||
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
|
||||
bereit: true,
|
||||
laeuft: false,
|
||||
fortschritt: 100,
|
||||
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
setzen({
|
||||
letzteErfolgreichePruefung: new Date().toISOString(),
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: 'Rippy ist aktuell.',
|
||||
})
|
||||
})
|
||||
autoUpdater.on('error', (fehler) => {
|
||||
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
||||
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
|
||||
})
|
||||
|
||||
pruefen()
|
||||
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
||||
}
|
||||
|
||||
function pruefen(): void {
|
||||
if (ripLaeuftJetzt()) return
|
||||
setzen({ laeuft: true, text: 'Suche nach Updates …' })
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt prüfen" im Fenster. Antwortet IMMER etwas — ein
|
||||
* Knopf, der nichts sagt, ist schlimmer als keiner. */
|
||||
export function updatePruefenJetzt(): void {
|
||||
if (!scharf) {
|
||||
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
|
||||
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
|
||||
aenderungMelden()
|
||||
return
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
|
||||
return
|
||||
}
|
||||
pruefen()
|
||||
}
|
||||
|
||||
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
|
||||
* er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */
|
||||
function installerAnwerfen(): { ok: boolean; grund: string } {
|
||||
if (installerGestartet) return { ok: true, grund: '' }
|
||||
if (installerPfad.length === 0) {
|
||||
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
|
||||
}
|
||||
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
|
||||
if (!urteil.ok) {
|
||||
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
|
||||
return { ok: false, grund: urteil.fehler }
|
||||
}
|
||||
installerGestartet = true
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn
|
||||
* wirklich etwas bereitliegt — und nie mitten in einem Rip. */
|
||||
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
|
||||
if (!scharf || !stand.bereit) {
|
||||
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
|
||||
}
|
||||
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
|
||||
// weg ist, und startet es hinterher wieder (`--force-run`).
|
||||
const urteil = installerAnwerfen()
|
||||
if (!urteil.ok) return urteil
|
||||
rippyBeenden()
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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 ''
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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'
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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)}`
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
|
||||
//
|
||||
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
|
||||
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
|
||||
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
|
||||
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
|
||||
//
|
||||
// Drei Sicherungen, alle bewusst:
|
||||
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
|
||||
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
|
||||
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
|
||||
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
|
||||
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
|
||||
// Weg, wie Rippy Daten verlieren könnte.
|
||||
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
|
||||
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
|
||||
import { join } from 'node:path'
|
||||
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
|
||||
|
||||
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
|
||||
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
|
||||
export const STANDARD_AUFBEWAHRUNG = '7'
|
||||
|
||||
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
|
||||
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
|
||||
const text = (wert ?? '').trim().toLowerCase()
|
||||
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
|
||||
if (text === 'sofort' || text === '0') return { art: 'sofort' }
|
||||
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
|
||||
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
|
||||
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
|
||||
}
|
||||
|
||||
export function regelText(regel: AufbewahrungsRegel): string {
|
||||
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
|
||||
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
|
||||
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
|
||||
}
|
||||
|
||||
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
|
||||
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
|
||||
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
|
||||
const beendet = new Date(beendetIso)
|
||||
if (Number.isNaN(beendet.getTime())) return ''
|
||||
if (regel.art === 'sofort') return beendet.toISOString()
|
||||
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
|
||||
export interface AufraeumKandidat {
|
||||
id: string
|
||||
phase: string
|
||||
komprimiert: boolean
|
||||
rohOrdner: string
|
||||
rohGeloeschtAm: string | null
|
||||
beendet: string | null
|
||||
fertigeDateien: readonly string[]
|
||||
}
|
||||
|
||||
export interface AufraeumZugriff {
|
||||
existiert: (pfad: string) => boolean
|
||||
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
|
||||
groesse: (pfad: string) => number
|
||||
ordnerLoeschen: (pfad: string) => void
|
||||
}
|
||||
|
||||
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
|
||||
existiert: existsSync,
|
||||
groesse: (pfad) => {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
|
||||
return 0
|
||||
}
|
||||
},
|
||||
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
|
||||
}
|
||||
|
||||
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
|
||||
export function loeschHindernis(
|
||||
v: AufraeumKandidat,
|
||||
regel: AufbewahrungsRegel,
|
||||
jetzt: Date,
|
||||
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||
): string {
|
||||
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
|
||||
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
|
||||
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
|
||||
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
|
||||
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
|
||||
for (const datei of v.fertigeDateien) {
|
||||
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
|
||||
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
|
||||
}
|
||||
if (regel.art === 'behalten') return 'Regel: behalten'
|
||||
const frist = faelligAm(v.beendet, regel)
|
||||
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
|
||||
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
|
||||
return ''
|
||||
}
|
||||
|
||||
export interface AufraeumErgebnis {
|
||||
geloescht: string[]
|
||||
uebersprungen: Array<{ id: string; grund: string }>
|
||||
fehler: Array<{ id: string; grund: string }>
|
||||
}
|
||||
|
||||
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
|
||||
export function aufraeumen(
|
||||
vorgaenge: readonly AufraeumKandidat[],
|
||||
regel: AufbewahrungsRegel,
|
||||
jetzt: Date,
|
||||
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||
): AufraeumErgebnis {
|
||||
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
|
||||
for (const v of vorgaenge) {
|
||||
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
|
||||
if (hindernis.length > 0) {
|
||||
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
zugriff.ordnerLoeschen(v.rohOrdner)
|
||||
ergebnis.geloescht.push(v.id)
|
||||
} catch (fehler) {
|
||||
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
|
||||
// gesagt — beim nächsten Takt kommt er wieder dran.
|
||||
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
|
||||
export function ordnerGroesse(pfad: string): number {
|
||||
let summe = 0
|
||||
const gehen = (ordner: string): void => {
|
||||
let eintraege: import('node:fs').Dirent[]
|
||||
try {
|
||||
eintraege = readdirSync(ordner, { withFileTypes: true })
|
||||
} catch {
|
||||
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
|
||||
// erfunden.
|
||||
return
|
||||
}
|
||||
for (const e of eintraege) {
|
||||
const voll = join(ordner, e.name)
|
||||
if (e.isDirectory()) gehen(voll)
|
||||
else {
|
||||
try {
|
||||
summe += statSync(voll).size
|
||||
} catch {
|
||||
// siehe oben
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(pfad)) gehen(pfad)
|
||||
return summe
|
||||
}
|
||||
|
||||
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
||||
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
|
||||
if (!existsSync(rohWurzel)) return []
|
||||
const liste: FremderOrdner[] = []
|
||||
let namen: string[]
|
||||
try {
|
||||
namen = readdirSync(rohWurzel, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
} catch (fehler) {
|
||||
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
|
||||
// bleibt dann leer, weil wirklich nichts gesehen wurde.
|
||||
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
|
||||
return liste
|
||||
}
|
||||
for (const name of namen) {
|
||||
const voll = join(rohWurzel, name)
|
||||
if (bekannte.has(voll) || bekannte.has(name)) continue
|
||||
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
|
||||
}
|
||||
return liste.sort((a, b) => b.bytes - a.bytes)
|
||||
}
|
||||
|
||||
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
|
||||
* darf „fremden Ordner löschen" anfassen — nie etwas anderes. */
|
||||
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
|
||||
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
|
||||
const kandidat = pfad.replace(/[\\/]+$/, '')
|
||||
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
|
||||
const rest = kandidat.slice(wurzel.length)
|
||||
return /^[\\/][^\\/]+$/.test(rest)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// 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'
|
||||
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
|
||||
// Auswahl-Dialog rechnet mit derselben Funktion).
|
||||
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
|
||||
|
||||
export { sichererName }
|
||||
|
||||
/** 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, staffelOrdnerName(staffel))
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
|
||||
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
|
||||
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
|
||||
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
|
||||
// noch komprimiert"),
|
||||
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
|
||||
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
|
||||
// Neulesen mehr.
|
||||
//
|
||||
// Was hier NEU ist gegenüber 5.3.x:
|
||||
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
|
||||
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
|
||||
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
|
||||
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
|
||||
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
|
||||
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
|
||||
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
|
||||
// TMDb gibt dann nur noch eine Warnung.
|
||||
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
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, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
|
||||
import {
|
||||
folgenNummern,
|
||||
folgenPlausibel,
|
||||
gewichteterFortschritt,
|
||||
sprachwahl,
|
||||
type KompressionsAuftrag,
|
||||
} from './auftrag'
|
||||
|
||||
export interface AblageUmgebung {
|
||||
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
|
||||
handbrake: () => string
|
||||
/** Wurzel der Ablage. */
|
||||
ablage: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
|
||||
episodenLaufzeiten?: (
|
||||
tmdbId: number,
|
||||
staffel: number,
|
||||
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
jetzt?: () => Date
|
||||
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
|
||||
verschieben?: (von: string, nach: string) => void
|
||||
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
|
||||
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
|
||||
}
|
||||
|
||||
export interface AblageErgebnis {
|
||||
status: 'fertig' | 'fehler' | 'abgebrochen'
|
||||
zielOrdner: string
|
||||
dateien: string[]
|
||||
text: string
|
||||
komprimiert: boolean
|
||||
preset: string
|
||||
fertigBytes: number
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
|
||||
|
||||
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
|
||||
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
|
||||
export function dateiVerschieben(von: string, nach: string): void {
|
||||
try {
|
||||
renameSync(von, nach)
|
||||
} catch (fehler) {
|
||||
const code = (fehler as NodeJS.ErrnoException).code
|
||||
if (code !== 'EXDEV') throw fehler
|
||||
copyFileSync(von, nach)
|
||||
unlinkSync(von)
|
||||
}
|
||||
}
|
||||
|
||||
function groesseVon(pfad: string): number {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
|
||||
// Ergebnis selbst.
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function komprimierenUndAblegen(
|
||||
auftrag: KompressionsAuftrag,
|
||||
umgebung: AblageUmgebung,
|
||||
melden: AblageMeldung,
|
||||
abbruch: AbortSignal,
|
||||
): Promise<AblageErgebnis> {
|
||||
const einstellungen = umgebung.einstellungen()
|
||||
const handbrake = umgebung.handbrake()
|
||||
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
|
||||
const komprimiert = soll && handbrake.length > 0
|
||||
const preset =
|
||||
auftrag.preset.length > 0
|
||||
? auftrag.preset
|
||||
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
|
||||
const zuordnung = auftrag.zuordnung
|
||||
const hinweise: string[] = [...auftrag.hinweise]
|
||||
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
|
||||
|
||||
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
|
||||
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
|
||||
const alsSerie = dateien.some((d) => d.rolle === 'folge')
|
||||
const staffel = auftrag.staffel ?? 1
|
||||
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||
const fertigOrdner =
|
||||
auftrag.zielOrdner.length > 0
|
||||
? auftrag.zielOrdner
|
||||
: alsSerie
|
||||
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
|
||||
: zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
|
||||
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
|
||||
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
|
||||
const extrasOrdner = join(fertigOrdner, 'extras')
|
||||
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
|
||||
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
|
||||
|
||||
const global = {
|
||||
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
}
|
||||
const gewichte = dateien.map((d) => d.groesseBytes)
|
||||
const scannen = umgebung.spurenScannen ?? spurenScannen
|
||||
const verschieben = umgebung.verschieben ?? dateiVerschieben
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||
// Episoden-Zuordnung weiter unten.
|
||||
const folgenSekunden: number[] = []
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const datei = dateien[i]
|
||||
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
|
||||
if (abbruch.aborted) {
|
||||
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
}
|
||||
if (!komprimiert) {
|
||||
melden({
|
||||
phase: 'legt-ab',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
|
||||
})
|
||||
try {
|
||||
verschieben(datei.pfad, ausgabe)
|
||||
} catch (fehler) {
|
||||
return {
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: fertigeDateien,
|
||||
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
fertigeDateien.push(ausgabe)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
continue
|
||||
}
|
||||
|
||||
const wahl = sprachwahl(datei, global)
|
||||
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
||||
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
||||
// globale Vorgabe war schon immer „was da ist".
|
||||
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
||||
const spuren = await scannen(handbrake, datei.pfad)
|
||||
if (spuren !== null) {
|
||||
const fehlt = fehlendeSprachen(spuren, wahl)
|
||||
if (fehlt.audio.length > 0) {
|
||||
hinweise.push(
|
||||
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
|
||||
)
|
||||
}
|
||||
if (fehlt.untertitel.length > 0) {
|
||||
hinweise.push(
|
||||
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
|
||||
}
|
||||
}
|
||||
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
||||
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
||||
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
||||
})
|
||||
const encode = await komprimieren({
|
||||
programm: handbrake,
|
||||
eingabe: datei.pfad,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: wahl.audio,
|
||||
untertitelSprachen: wahl.untertitel,
|
||||
untertitelStandard: wahl.untertitelStandard,
|
||||
abbruch,
|
||||
aufFortschritt: (prozent) => {
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, prozent),
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
|
||||
})
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') {
|
||||
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
}
|
||||
if (encode.status === 'error') {
|
||||
return {
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: fertigeDateien,
|
||||
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
|
||||
komprimiert: true,
|
||||
preset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
}
|
||||
|
||||
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
|
||||
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
|
||||
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
|
||||
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
|
||||
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||
if (alsSerie && folgenSekunden.length > 0) {
|
||||
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||
const laufzeiten =
|
||||
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||
let episoden: number[] | null = null
|
||||
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
|
||||
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
|
||||
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
|
||||
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
|
||||
} else {
|
||||
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||
}
|
||||
if (episoden !== null) {
|
||||
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||
} else {
|
||||
hinweise.push(
|
||||
laufzeiten.length === 0
|
||||
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
|
||||
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
|
||||
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||
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',
|
||||
},
|
||||
anzeigeTitel,
|
||||
zuordnung?.jahr ?? null,
|
||||
),
|
||||
)
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
hinweise.push(`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)
|
||||
|
||||
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
|
||||
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
|
||||
// unter dem alten Namen nicht mehr gibt.
|
||||
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
|
||||
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
|
||||
let fertigBytes = 0
|
||||
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
|
||||
|
||||
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||
const jetzt = umgebung.jetzt?.() ?? new Date()
|
||||
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
|
||||
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
|
||||
umgebung.bibliothekEintragen({
|
||||
fingerprint: auftrag.fingerprint,
|
||||
titel: anzeigeTitel,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
typ: auftrag.discTyp,
|
||||
groesseBytes: fertigBytes,
|
||||
ablageort: fertigOrdner,
|
||||
datum: begonnen.toISOString(),
|
||||
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
|
||||
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
const text =
|
||||
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
|
||||
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||
(auftrag.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '')
|
||||
return {
|
||||
status: 'fertig',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: endgueltig,
|
||||
text,
|
||||
komprimiert,
|
||||
preset: komprimiert ? preset : '',
|
||||
fertigBytes,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
function abgebrochen(
|
||||
zielOrdner: string,
|
||||
dateien: string[],
|
||||
preset: string,
|
||||
komprimiert: boolean,
|
||||
hinweise: string[],
|
||||
): AblageErgebnis {
|
||||
return {
|
||||
status: 'abgebrochen',
|
||||
zielOrdner,
|
||||
dateien,
|
||||
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||
komprimiert,
|
||||
preset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle MKVs im Zielordner (und in extras\) — nach dem Umbenennen der Folgen
|
||||
* ist das die einzige verlässliche Liste. */
|
||||
function vorhandeneMkv(ordner: string, extras: string): string[] {
|
||||
const liste: string[] = []
|
||||
for (const o of [ordner, extras]) {
|
||||
if (!existsSync(o)) continue
|
||||
try {
|
||||
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
|
||||
} catch {
|
||||
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
|
||||
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
|
||||
}
|
||||
}
|
||||
return liste.sort()
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
|
||||
//
|
||||
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
|
||||
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
|
||||
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
|
||||
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
|
||||
export interface RohDatei {
|
||||
pfad: string
|
||||
titelNr: number
|
||||
rolle: TitelRolle
|
||||
dauerS: number
|
||||
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
|
||||
groesseBytes: number
|
||||
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
|
||||
audioSprachen: string[]
|
||||
untertitelSprachen: string[]
|
||||
}
|
||||
|
||||
export interface KompressionsAuftrag {
|
||||
vorgangId: string
|
||||
laufwerk: string
|
||||
discTyp: string
|
||||
rohOrdner: string
|
||||
dateien: RohDatei[]
|
||||
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
|
||||
zuordnung: Zuordnung | null
|
||||
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
|
||||
staffel: number | null
|
||||
ersteFolge: number | null
|
||||
discNr: number | null
|
||||
fingerprint: string | null
|
||||
lesefehler: boolean
|
||||
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
|
||||
hinweise: string[]
|
||||
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
|
||||
begonnen: string
|
||||
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
|
||||
preset: string
|
||||
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs — die neue
|
||||
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
|
||||
* '' = Zielordner wird frisch bestimmt. */
|
||||
zielOrdner: string
|
||||
}
|
||||
|
||||
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
|
||||
return JSON.stringify(auftrag)
|
||||
}
|
||||
|
||||
/** Liest einen gemerkten Auftrag zurück — null, wenn er nicht die Form hat
|
||||
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
|
||||
export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
||||
let roh: unknown
|
||||
try {
|
||||
roh = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof roh !== 'object' || roh === null) return null
|
||||
const a = roh as Record<string, unknown>
|
||||
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
|
||||
const dateien: RohDatei[] = []
|
||||
for (const d of a.dateien) {
|
||||
if (typeof d !== 'object' || d === null) return null
|
||||
const e = d as Record<string, unknown>
|
||||
if (typeof e.pfad !== 'string') return null
|
||||
dateien.push({
|
||||
pfad: e.pfad,
|
||||
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
|
||||
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
|
||||
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
|
||||
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
|
||||
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||
})
|
||||
}
|
||||
return {
|
||||
vorgangId: a.vorgangId,
|
||||
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
|
||||
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
|
||||
rohOrdner: a.rohOrdner,
|
||||
dateien,
|
||||
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
|
||||
staffel: typeof a.staffel === 'number' ? a.staffel : null,
|
||||
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
|
||||
discNr: typeof a.discNr === 'number' ? a.discNr : null,
|
||||
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
|
||||
lesefehler: a.lesefehler === true,
|
||||
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
|
||||
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
|
||||
preset: typeof a.preset === 'string' ? a.preset : '',
|
||||
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Was HandBrake für EINE Datei bekommt. */
|
||||
export interface SprachWahl {
|
||||
audio: string[]
|
||||
untertitel: string[]
|
||||
/** Die erste Untertitelspur vorwählen — weil der Ton nicht in der
|
||||
* Wunschsprache ist. */
|
||||
untertitelStandard: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
|
||||
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
|
||||
* „japanischer Ton und braucht dringend die deutschen untertitel" —
|
||||
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
|
||||
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
|
||||
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
|
||||
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
|
||||
* komprimieren/handbrake.ts).
|
||||
*/
|
||||
export function sprachwahl(
|
||||
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
|
||||
global: { audio: readonly string[]; untertitel: readonly string[] },
|
||||
): SprachWahl {
|
||||
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
|
||||
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
|
||||
const wunsch = global.audio[0] ?? ''
|
||||
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
|
||||
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
|
||||
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
|
||||
return { audio, untertitel, untertitelStandard }
|
||||
}
|
||||
|
||||
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
||||
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
||||
const start = Math.max(1, Math.trunc(ersteFolge))
|
||||
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
|
||||
* Vorher sprang der Balken je Titel auf null zurück — bei 2 × 16 GB stand
|
||||
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
|
||||
* Teile gleich. `prozent` < 0 heißt „vom laufenden Teil noch keine Angabe".
|
||||
*/
|
||||
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
|
||||
if (gewichte.length === 0) return -1
|
||||
const gleich = gewichte.some((g) => !(g > 0))
|
||||
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
|
||||
const gesamt = g.reduce((s, w) => s + w, 0)
|
||||
if (gesamt <= 0) return -1
|
||||
const i = Math.min(Math.max(0, index), g.length - 1)
|
||||
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
|
||||
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
|
||||
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
|
||||
}
|
||||
|
||||
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
|
||||
* Warnung — der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
|
||||
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
|
||||
export function folgenPlausibel(
|
||||
dateiSekunden: readonly number[],
|
||||
nummern: readonly number[],
|
||||
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
|
||||
toleranz = 0.15,
|
||||
): string[] {
|
||||
const warnungen: string[] = []
|
||||
for (let i = 0; i < nummern.length; i++) {
|
||||
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
|
||||
const ist = dateiSekunden[i] ?? 0
|
||||
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
|
||||
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
|
||||
warnungen.push(
|
||||
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return warnungen
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
// 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).
|
||||
//
|
||||
// Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den
|
||||
// RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions-
|
||||
// Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts →
|
||||
// ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien
|
||||
// liegen — die nächste Disc kann rein, während die vorige noch rechnet
|
||||
// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz
|
||||
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
|
||||
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
|
||||
// nach einem Abbruch nachsehen.
|
||||
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
BibliothekEintrag,
|
||||
GeraeteInfo,
|
||||
RipAuftragOptionen,
|
||||
RipAuftragTitel,
|
||||
RipStatus,
|
||||
TitelRolle,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import 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 { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdtempSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
abbruch: AbortController
|
||||
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||
titelAbbruch?: AbortController
|
||||
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||
uebersprungen?: boolean
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */
|
||||
export interface VorgangBeginn {
|
||||
id: string
|
||||
laufwerk: string
|
||||
typ: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
rohOrdner: string
|
||||
begonnen: string
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
|
||||
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) — für
|
||||
* Audio-CD und ISO, die keine Kompression durchlaufen. */
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
/** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht
|
||||
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
|
||||
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
|
||||
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
|
||||
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
|
||||
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
|
||||
vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void
|
||||
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) — die nächste
|
||||
* Disc soll rein, während die Kompression läuft. */
|
||||
nachRipAuswerfen?: (id: string) => void
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||
* während Folge 2 heil daneben lag). */
|
||||
ueberspringen(id: string): void {
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||
vorgang.uebersprungen = true
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||
async starten(
|
||||
id: string,
|
||||
auftrag: readonly RipAuftragTitel[] = [],
|
||||
optionen: RipAuftragOptionen = {},
|
||||
): 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 vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
||||
|
||||
// § 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)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||
this.umgebung.vorgangBeginnt?.({
|
||||
id: vorgangsName,
|
||||
laufwerk: id,
|
||||
typ: geraet.typ,
|
||||
titel: anzeigeTitel,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
rohOrdner,
|
||||
begonnen: startZeit.toISOString(),
|
||||
})
|
||||
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
|
||||
// vorher sprang er je Titel zurück auf null.
|
||||
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
|
||||
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
const aufgeraeumt: string[] = []
|
||||
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName },
|
||||
})
|
||||
|
||||
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
||||
this.laufend.delete(id)
|
||||
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
||||
// Punkt „Aufräumen nach Abbruch").
|
||||
if (alleDateien.size === 0) {
|
||||
try {
|
||||
rmSync(rohOrdner, { recursive: true, force: true })
|
||||
} catch (fehler) {
|
||||
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
||||
}
|
||||
}
|
||||
this.umgebung.vorgangEndet?.(vorgangsName, phase, text)
|
||||
this.melden({
|
||||
id,
|
||||
phase,
|
||||
prozent: -1,
|
||||
text,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < laeufe.length; i++) {
|
||||
const welcher = laeufe[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||
const titelAbbruch = new AbortController()
|
||||
const weiterreichen = (): void => titelAbbruch.abort()
|
||||
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang !== undefined) {
|
||||
vorgang.titelAbbruch = titelAbbruch
|
||||
vorgang.uebersprungen = false
|
||||
}
|
||||
|
||||
const beschriftung =
|
||||
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: gewichteterFortschritt(gewichte, i, -1),
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
titelGesamt: laeufe.length,
|
||||
haengt: null,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
|
||||
// Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch
|
||||
// oder Fehler ist eine halbe Datei und wird entfernt (5.4.0).
|
||||
const vorher = dateienIn(rohOrdner)
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
})
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
beenden(
|
||||
'abgebrochen',
|
||||
'Abgebrochen.' +
|
||||
(aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') +
|
||||
(alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''),
|
||||
)
|
||||
return
|
||||
}
|
||||
uebersprungen.push(beschriftung)
|
||||
continue
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||
for (const d of rip.dateien) alleDateien.add(d)
|
||||
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||
}
|
||||
}
|
||||
|
||||
const nachLauf = this.laufend.get(id)
|
||||
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||
|
||||
if (alleDateien.size === 0) {
|
||||
beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.')
|
||||
return
|
||||
}
|
||||
|
||||
const hinweise: string[] = []
|
||||
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
|
||||
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
|
||||
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
|
||||
|
||||
// ── Übergabe an die Warteschlange (5.4.0) ──────────────────────────
|
||||
const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => {
|
||||
const a = auftragJeDatei.get(pfad)
|
||||
const rolle: TitelRolle = a?.rolle ?? 'hauptfilm'
|
||||
return {
|
||||
pfad,
|
||||
titelNr: a?.nr ?? -1,
|
||||
rolle,
|
||||
dauerS: a?.dauerS ?? 0,
|
||||
groesseBytes: groesseVon(pfad),
|
||||
audioSprachen: a?.audioSprachen ?? [],
|
||||
untertitelSprachen: a?.untertitelSprachen ?? [],
|
||||
}
|
||||
})
|
||||
const angaben = this.umgebung.discAngaben?.(id) ?? null
|
||||
const kompression: KompressionsAuftrag = {
|
||||
vorgangId: vorgangsName,
|
||||
laufwerk: id,
|
||||
discTyp: geraet.typ,
|
||||
rohOrdner,
|
||||
dateien,
|
||||
zuordnung,
|
||||
staffel: optionen.staffel ?? angaben?.staffel ?? null,
|
||||
ersteFolge: optionen.ersteFolge ?? null,
|
||||
discNr: angaben?.discNr ?? null,
|
||||
fingerprint: this.umgebung.fingerprint?.(id) ?? null,
|
||||
lesefehler: lesefehlerGesamt,
|
||||
hinweise,
|
||||
begonnen: startZeit.toISOString(),
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
}
|
||||
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0
|
||||
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
|
||||
|
||||
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
|
||||
this.laufend.delete(id)
|
||||
this.umgebung.nachRipAuswerfen?.(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text:
|
||||
`Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
|
||||
(platz > 0
|
||||
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
|
||||
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
|
||||
(lesefehlerGesamt
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '') +
|
||||
(hinweise.length > 0 ? ` ⚠ ${hinweise.join(' ')}` : ''),
|
||||
dateien: dateien.map((d) => d.pfad),
|
||||
lesefehler: lesefehlerGesamt,
|
||||
vorgangId: vorgangsName,
|
||||
})
|
||||
}
|
||||
|
||||
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) groesse += groesseVon(datei)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
|
||||
}
|
||||
|
||||
function groesseVon(pfad: string): number {
|
||||
try {
|
||||
return statSync(pfad).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an
|
||||
// der Liste selbst.
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function dateienIn(ordner: string): Set<string> {
|
||||
try {
|
||||
return new Set(readdirSync(ordner))
|
||||
} catch {
|
||||
// Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und
|
||||
// im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist.
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt, was seit `vorher` im Ordner dazukam — die halbe Datei eines
|
||||
* abgebrochenen oder gescheiterten Titels. Liefert die Namen. */
|
||||
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>): string[] {
|
||||
const entfernt: string[] = []
|
||||
for (const name of dateienIn(ordner)) {
|
||||
if (vorher.has(name)) continue
|
||||
try {
|
||||
unlinkSync(join(ordner, name))
|
||||
entfernt.push(name)
|
||||
} catch (fehler) {
|
||||
// Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da.
|
||||
entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`)
|
||||
}
|
||||
}
|
||||
return entfernt
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = werkzeugSpawn(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') }))
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
|
||||
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
|
||||
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
|
||||
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
|
||||
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
|
||||
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
|
||||
// werden zusammen nicht schneller.
|
||||
//
|
||||
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
|
||||
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
|
||||
import type { KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KompressionsAuftrag } from './auftrag'
|
||||
import type { AblageErgebnis, AblageMeldung } from './ablegen'
|
||||
|
||||
export type Ausfuehren = (
|
||||
auftrag: KompressionsAuftrag,
|
||||
melden: AblageMeldung,
|
||||
abbruch: AbortSignal,
|
||||
) => Promise<AblageErgebnis>
|
||||
|
||||
interface Eintrag {
|
||||
auftrag: KompressionsAuftrag
|
||||
titel: string
|
||||
abbruch: AbortController
|
||||
}
|
||||
|
||||
export interface WarteschlangenStand {
|
||||
laeuft: KompressionStand | null
|
||||
wartend: KompressionStand[]
|
||||
}
|
||||
|
||||
export class KompressionsWarteschlange {
|
||||
private wartend: Eintrag[] = []
|
||||
private laufend: Eintrag | null = null
|
||||
private laufendStand: KompressionStand | null = null
|
||||
private arbeitet = false
|
||||
|
||||
constructor(
|
||||
private readonly ausfuehren: Ausfuehren,
|
||||
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
|
||||
private readonly beiStand: (stand: WarteschlangenStand) => void,
|
||||
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
|
||||
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
|
||||
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
|
||||
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
|
||||
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
|
||||
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
|
||||
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
|
||||
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
|
||||
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
|
||||
this.beiStand(this.stand())
|
||||
void this.schleife()
|
||||
return platz
|
||||
}
|
||||
|
||||
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
|
||||
abbrechen(vorgangId: string): boolean {
|
||||
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
|
||||
if (i >= 0) {
|
||||
const [weg] = this.wartend.splice(i, 1)
|
||||
this.beiStand(this.stand())
|
||||
void this.beiErgebnis(weg.auftrag, {
|
||||
status: 'abgebrochen',
|
||||
zielOrdner: '',
|
||||
dateien: [],
|
||||
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (this.laufend?.auftrag.vorgangId === vorgangId) {
|
||||
this.laufend.abbruch.abort()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
stand(): WarteschlangenStand {
|
||||
return {
|
||||
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
|
||||
wartend: this.wartend.map((e) => ({
|
||||
vorgangId: e.auftrag.vorgangId,
|
||||
titel: e.titel,
|
||||
prozent: -1,
|
||||
text: 'wartet',
|
||||
phase: 'wartet',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
laeuftEtwas(): boolean {
|
||||
return this.laufend !== null || this.wartend.length > 0
|
||||
}
|
||||
|
||||
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
|
||||
kennt(vorgangId: string): boolean {
|
||||
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
|
||||
}
|
||||
|
||||
private async schleife(): Promise<void> {
|
||||
if (this.arbeitet) return
|
||||
this.arbeitet = true
|
||||
try {
|
||||
while (this.wartend.length > 0) {
|
||||
const eintrag = this.wartend.shift()!
|
||||
this.laufend = eintrag
|
||||
this.laufendStand = {
|
||||
vorgangId: eintrag.auftrag.vorgangId,
|
||||
titel: eintrag.titel,
|
||||
prozent: -1,
|
||||
text: 'Beginne …',
|
||||
phase: 'komprimiert',
|
||||
}
|
||||
this.beiStand(this.stand())
|
||||
let ergebnis: AblageErgebnis
|
||||
try {
|
||||
ergebnis = await this.ausfuehren(
|
||||
eintrag.auftrag,
|
||||
(stand) => {
|
||||
if (this.laufendStand === null) return
|
||||
this.laufendStand = { ...this.laufendStand, ...stand }
|
||||
this.beiStand(this.stand())
|
||||
},
|
||||
eintrag.abbruch.signal,
|
||||
)
|
||||
} catch (fehler) {
|
||||
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
|
||||
// kein stilles Ende der Schlange.
|
||||
ergebnis = {
|
||||
status: 'fehler',
|
||||
zielOrdner: '',
|
||||
dateien: [],
|
||||
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
}
|
||||
}
|
||||
this.laufend = null
|
||||
this.laufendStand = null
|
||||
try {
|
||||
await this.beiErgebnis(eintrag.auftrag, ergebnis)
|
||||
} catch (fehler) {
|
||||
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
|
||||
}
|
||||
this.beiStand(this.stand())
|
||||
}
|
||||
} finally {
|
||||
this.arbeitet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// 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 { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
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 = werkzeugSpawn(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) }
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// 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 { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
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[]
|
||||
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
||||
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
||||
untertitelStandard?: boolean
|
||||
}
|
||||
|
||||
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
||||
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
|
||||
// ffprobe geprüft — Regel D):
|
||||
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
|
||||
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
|
||||
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
|
||||
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
|
||||
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
|
||||
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
|
||||
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
||||
// sie damit von selbst)
|
||||
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
||||
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')
|
||||
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
|
||||
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
|
||||
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
|
||||
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[]
|
||||
untertitelStandard?: boolean
|
||||
/** 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 = werkzeugSpawn(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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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',
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
|
||||
// WIRKLICH in der Roh-Datei? (5.4.0)
|
||||
//
|
||||
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
|
||||
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
|
||||
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
|
||||
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
|
||||
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
|
||||
// Wunsch still durchrutscht.
|
||||
//
|
||||
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
|
||||
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
|
||||
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
|
||||
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
|
||||
// Objekt endet also NICHT am Textende — es wird über die Klammern
|
||||
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
|
||||
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
export interface HbSpuren {
|
||||
audio: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||
dauerS: number
|
||||
}
|
||||
|
||||
export function buildScanArgs(datei: string): string[] {
|
||||
return ['--scan', '--json', '--input', datei, '--title', '1']
|
||||
}
|
||||
|
||||
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
|
||||
export function spurenAusScan(ausgabe: string): HbSpuren | null {
|
||||
const marke = ausgabe.indexOf('JSON Title Set:')
|
||||
if (marke < 0) return null
|
||||
const start = ausgabe.indexOf('{', marke)
|
||||
if (start < 0) return null
|
||||
let tiefe = 0
|
||||
let ende = -1
|
||||
for (let i = start; i < ausgabe.length; i++) {
|
||||
const zeichen = ausgabe[i]
|
||||
if (zeichen === '{') tiefe += 1
|
||||
else if (zeichen === '}') {
|
||||
tiefe -= 1
|
||||
if (tiefe === 0) {
|
||||
ende = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ende < 0) return null
|
||||
let daten: unknown
|
||||
try {
|
||||
daten = JSON.parse(ausgabe.slice(start, ende + 1))
|
||||
} catch {
|
||||
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
|
||||
// erfundener leerer Befund (R2).
|
||||
return null
|
||||
}
|
||||
if (typeof daten !== 'object' || daten === null) return null
|
||||
const liste = (daten as { TitleList?: unknown }).TitleList
|
||||
if (!Array.isArray(liste) || liste.length === 0) return null
|
||||
const titel = liste[0] as Record<string, unknown>
|
||||
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
|
||||
if (!Array.isArray(feld)) return []
|
||||
return feld.map((s, i) => {
|
||||
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||
return {
|
||||
nr: i + 1,
|
||||
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
||||
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
|
||||
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
||||
return {
|
||||
audio: spuren(titel.AudioList),
|
||||
untertitel: spuren(titel.SubtitleList),
|
||||
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
|
||||
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
|
||||
return new Promise((fertig) => {
|
||||
if (programm.length === 0) {
|
||||
fertig(null)
|
||||
return
|
||||
}
|
||||
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { 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(null)
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
|
||||
export function fehlendeSprachen(
|
||||
spuren: HbSpuren,
|
||||
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
|
||||
): { audio: string[]; untertitel: string[] } {
|
||||
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
|
||||
const audioDa = da(spuren.audio)
|
||||
const untertitelDa = da(spuren.untertitel)
|
||||
return {
|
||||
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
|
||||
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
|
||||
//
|
||||
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
|
||||
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
|
||||
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
|
||||
// sie selbst.
|
||||
//
|
||||
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
|
||||
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
|
||||
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
|
||||
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
|
||||
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
|
||||
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
|
||||
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
|
||||
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
|
||||
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
|
||||
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
|
||||
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
|
||||
// Pfad-Konverter, über spawn nicht).
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
export interface LaufwerkEreignis {
|
||||
/** ISO-Zeit (UTC). */
|
||||
zeit: string
|
||||
provider: string
|
||||
id: number
|
||||
geraet: string
|
||||
}
|
||||
|
||||
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
|
||||
export const EREIGNIS_TEXTE: Record<number, string> = {
|
||||
7: 'fehlerhafter Block',
|
||||
11: 'Controllerfehler',
|
||||
15: 'Gerät nicht bereit',
|
||||
51: 'E/A-Fehler beim Lesen',
|
||||
153: 'E/A-Vorgang wiederholt',
|
||||
}
|
||||
|
||||
export function xpathFuer(stunden: number): string {
|
||||
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
|
||||
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
|
||||
}
|
||||
|
||||
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
|
||||
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
|
||||
}
|
||||
|
||||
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
|
||||
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
|
||||
const liste: LaufwerkEreignis[] = []
|
||||
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
|
||||
const block = treffer[0]
|
||||
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
|
||||
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
|
||||
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
|
||||
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
|
||||
if (!Number.isInteger(id) || provider.length === 0) continue
|
||||
liste.push({ zeit, provider, id, geraet })
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
||||
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
|
||||
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
|
||||
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
|
||||
let fehlerhafteBloecke = 0
|
||||
let controllerfehler = 0
|
||||
let sonstige = 0
|
||||
for (const e of relevant) {
|
||||
if (e.id === 7) fehlerhafteBloecke += 1
|
||||
else if (e.id === 11 || e.id === 15) controllerfehler += 1
|
||||
else sonstige += 1
|
||||
}
|
||||
const letzte = relevant.slice(0, 8).map((e) => {
|
||||
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
|
||||
const zeit =
|
||||
lokal !== null && !Number.isNaN(lokal.getTime())
|
||||
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
|
||||
: '?'
|
||||
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
|
||||
})
|
||||
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
|
||||
|
||||
if (relevant.length === 0) {
|
||||
return {
|
||||
urteil: 'ruhig',
|
||||
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
if (controllerfehler > 0) {
|
||||
return {
|
||||
urteil: 'hardware',
|
||||
text:
|
||||
`${controllerfehler} Controllerfehler ${zeitraum}` +
|
||||
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
|
||||
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
|
||||
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
if (fehlerhafteBloecke > 0) {
|
||||
return {
|
||||
urteil: 'disc',
|
||||
text:
|
||||
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
|
||||
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
|
||||
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
return {
|
||||
urteil: 'unklar',
|
||||
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
|
||||
fehlerhafteBloecke,
|
||||
controllerfehler,
|
||||
sonstige,
|
||||
letzte,
|
||||
stunden,
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
|
||||
export function ereignisseLesen(
|
||||
stunden: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
let kind: ReturnType<typeof spawn>
|
||||
try {
|
||||
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} catch (fehler) {
|
||||
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||
return
|
||||
}
|
||||
const stuecke: Buffer[] = []
|
||||
const fehlerStuecke: Buffer[] = []
|
||||
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
|
||||
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
|
||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||
})
|
||||
kind.on('close', (code) => {
|
||||
clearTimeout(zeiger)
|
||||
const xml = textVon(Buffer.concat(stuecke))
|
||||
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
|
||||
// steht auf stderr mit Code ≠ 0.
|
||||
if (code !== 0 && xml.trim().length === 0) {
|
||||
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
|
||||
return
|
||||
}
|
||||
fertig({ ereignisse: ereignisseAusXml(xml) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// 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()]
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
|
||||
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
|
||||
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
|
||||
*
|
||||
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
|
||||
* <di:titleName titleNumber="1">EP 1</di:titleName>
|
||||
* <di:titleName titleNumber="2">EP 2</di:titleName>
|
||||
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> … usw.
|
||||
*
|
||||
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
|
||||
* Das ist ein Beweis, keine Vermutung — und schlägt jede Titel-Raterei. */
|
||||
export interface BdmtInhalt {
|
||||
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
|
||||
titel: string
|
||||
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
|
||||
eintraege: string[]
|
||||
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
|
||||
* (bdmt_deu neben bdmt_eng, 5.4.0) — ohne den Haupttitel, ohne Doppel. */
|
||||
alternativen: string[]
|
||||
}
|
||||
|
||||
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
|
||||
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
|
||||
* Authoring-Werkzeug). */
|
||||
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
|
||||
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
const eintraege: string[] = []
|
||||
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
|
||||
const text = t[1].trim()
|
||||
if (text.length > 0) eintraege.push(text)
|
||||
}
|
||||
return { titel: name === null ? '' : name[1].trim(), eintraege }
|
||||
}
|
||||
|
||||
/** Liest die bdmt-Dateien — bdmt_eng zuerst, die anderen Sprachen liefern
|
||||
* Titel-Varianten (5.4.0). */
|
||||
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
||||
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
|
||||
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
|
||||
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
|
||||
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return leer
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return leer
|
||||
}
|
||||
const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmts.length === 0) return leer
|
||||
// Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
|
||||
// Deutsch, dann der Rest in Dateireihenfolge.
|
||||
const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
|
||||
bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
|
||||
let titel = ''
|
||||
let eintraege: string[] = []
|
||||
const alternativen: string[] = []
|
||||
for (const bdmt of bdmts) {
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const zerlegt = bdmtZerlegen(inhalt)
|
||||
if (titel.length === 0 && zerlegt.titel.length > 0) {
|
||||
titel = zerlegt.titel
|
||||
eintraege = zerlegt.eintraege
|
||||
continue
|
||||
}
|
||||
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
|
||||
if (
|
||||
zerlegt.titel.length > 0 &&
|
||||
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
|
||||
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
|
||||
) {
|
||||
alternativen.push(zerlegt.titel)
|
||||
}
|
||||
}
|
||||
return { titel, eintraege, alternativen }
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
return inhaltAusBdmt(wurzel).titel
|
||||
}
|
||||
|
||||
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
|
||||
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
|
||||
* `101 LOGO` — Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
|
||||
export function istFolgenName(name: string): boolean {
|
||||
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
|
||||
}
|
||||
|
||||
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
|
||||
export function folgenZahl(eintraege: readonly string[]): number {
|
||||
return eintraege.filter(istFolgenName).length
|
||||
}
|
||||
|
||||
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
|
||||
// steht mit Absicht nicht hier:
|
||||
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
|
||||
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
|
||||
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
|
||||
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
|
||||
const TRENNER = String.raw`[\s\-–—_:,.]`
|
||||
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
|
||||
export interface DiscZusatz {
|
||||
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
|
||||
titel: string
|
||||
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
}
|
||||
|
||||
/** `Spartacus: Gods Of The Arena - Disc 2` → Titel + Disc 2 (pur).
|
||||
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
|
||||
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
|
||||
export function discZusatzAbtrennen(titel: string): DiscZusatz {
|
||||
let rest = titel.trim()
|
||||
let discNr: number | null = null
|
||||
let staffel: number | null = null
|
||||
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
|
||||
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
|
||||
for (let runde = 0; runde < 4; runde++) {
|
||||
const disc = DISC_MUSTER.exec(rest)
|
||||
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
|
||||
discNr = Number(disc[1])
|
||||
rest = rest.slice(0, disc.index)
|
||||
continue
|
||||
}
|
||||
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
|
||||
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
|
||||
staffel = Number(staffelTreffer[1])
|
||||
rest = rest.slice(0, staffelTreffer.index)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
||||
}
|
||||
|
||||
/** \\.\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 {
|
||||
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
|
||||
staffel: number | null
|
||||
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
|
||||
folgen: number
|
||||
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
|
||||
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
|
||||
istSerie: boolean
|
||||
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
|
||||
alternativen: string[]
|
||||
}
|
||||
|
||||
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
|
||||
export const FOLGEN_FUER_SERIE = 2
|
||||
|
||||
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
|
||||
const zusatz = discZusatzAbtrennen(titel)
|
||||
return {
|
||||
titel,
|
||||
quelle,
|
||||
discNr: zusatz.discNr,
|
||||
staffel: zusatz.staffel,
|
||||
folgen,
|
||||
istSerie: folgen >= FOLGEN_FUER_SERIE,
|
||||
alternativen,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alles, was die Disc über sich selbst sagt: 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)
|
||||
let folgen = 0
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = inhaltAusBdmt(wurzel)
|
||||
folgen = folgenZahl(bdmt.eintraege)
|
||||
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
|
||||
}
|
||||
|
||||
/** 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}`
|
||||
}
|
||||
|
||||
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
|
||||
export const HOECHSTENS_KANDIDATEN = 7
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre) — von SPEZIFISCH nach vage:
|
||||
*
|
||||
* voller Titel → ohne Disc-/Staffel-Zusatz → ohne Doppelpunkt →
|
||||
* ohne Klammern → hintere Worte strippen → Doppelpunkt-Vorspann
|
||||
*
|
||||
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
|
||||
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
|
||||
* ('Spartacus: Gods Of The Arena' → 'Spartacus'). TMDb hat einen Film,
|
||||
* der wörtlich so heißt — die richtige Serie wurde nie gefragt. Er
|
||||
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
|
||||
* (MINDEST_ABDECKUNG in zuordnung.ts). */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const nachlaufend = new RegExp(`${TRENNER}+$`)
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
|
||||
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||
}
|
||||
merke(titel)
|
||||
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
|
||||
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
|
||||
const ohneZusatz = discZusatzAbtrennen(titel).titel
|
||||
merke(ohneZusatz)
|
||||
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
|
||||
// beide Schreibweisen fragen.
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
|
||||
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
|
||||
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
|
||||
return kandidaten
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
// 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.
|
||||
//
|
||||
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
|
||||
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
|
||||
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
|
||||
// movie/149?append_to_response=images,credits,release_dates
|
||||
// &include_image_language=de,en,null
|
||||
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
|
||||
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
|
||||
// file_path:'/….png'|'.svg', width, height, vote_average}],
|
||||
// credits.cast[{name, character, profile_path, order}],
|
||||
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
|
||||
// tv/46296?append_to_response=images,credits,content_ratings
|
||||
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
|
||||
// seasons[{season_number, name, episode_count}], number_of_episodes
|
||||
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
|
||||
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
|
||||
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
|
||||
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
|
||||
|
||||
export interface FilmTreffer {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
poster_path?: string | null
|
||||
backdrop_path?: string | null
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
overview?: string
|
||||
popularity?: number
|
||||
}
|
||||
|
||||
export interface TmdbLogo {
|
||||
iso_639_1: string | null
|
||||
file_path: string
|
||||
width?: number
|
||||
height?: number
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
export interface TmdbCast {
|
||||
name: string
|
||||
character?: string
|
||||
profile_path?: string | null
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface FilmDetails {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
overview?: string
|
||||
poster_path?: string | null
|
||||
backdrop_path?: string | null
|
||||
runtime?: number
|
||||
genres?: Array<{ name: string }>
|
||||
tagline?: string
|
||||
vote_average?: number
|
||||
vote_count?: number
|
||||
popularity?: number
|
||||
episode_run_time?: number[]
|
||||
number_of_episodes?: number
|
||||
number_of_seasons?: number
|
||||
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
|
||||
images?: { logos?: TmdbLogo[] }
|
||||
credits?: { cast?: TmdbCast[] }
|
||||
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
|
||||
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
|
||||
}
|
||||
|
||||
export function istV4Token(key: string): boolean {
|
||||
return key.startsWith('eyJ')
|
||||
}
|
||||
|
||||
const BASIS = 'https://api.themoviedb.org/3'
|
||||
|
||||
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
|
||||
* bestbewertete. '' wenn keins. */
|
||||
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
|
||||
if (logos === undefined || logos.length === 0) return ''
|
||||
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
|
||||
const sortiert = [...logos]
|
||||
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
|
||||
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
|
||||
return sortiert[0]?.file_path ?? ''
|
||||
}
|
||||
|
||||
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
|
||||
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
|
||||
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
|
||||
if (typ === 'tv') {
|
||||
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||
return (de?.rating ?? '').trim()
|
||||
}
|
||||
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
|
||||
if (eintraege.length === 0) return ''
|
||||
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
|
||||
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
|
||||
}
|
||||
|
||||
/** Die ersten Darsteller mit Bild (pur). */
|
||||
export function besetzungAus(
|
||||
cast: readonly TmdbCast[] | undefined,
|
||||
hoechstens = 6,
|
||||
): Array<{ name: string; rolle: string; bildPfad: string }> {
|
||||
if (cast === undefined) return []
|
||||
return [...cast]
|
||||
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
|
||||
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
||||
.slice(0, hoechstens)
|
||||
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
|
||||
}
|
||||
|
||||
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',
|
||||
append_to_response: 'images,credits,release_dates',
|
||||
include_image_language: 'de,en,null',
|
||||
})) as FilmDetails | null
|
||||
}
|
||||
|
||||
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||
return (await this.anfrage(`tv/${id}`, {
|
||||
language: 'de-DE',
|
||||
append_to_response: 'images,credits,content_ratings',
|
||||
include_image_language: 'de,en,null',
|
||||
})) 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
|
||||
}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
// 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).
|
||||
//
|
||||
// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
|
||||
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
|
||||
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
|
||||
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
|
||||
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
|
||||
// nicht mehr als Treffer.
|
||||
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
|
||||
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
|
||||
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
|
||||
// auf einen Vorschlag zurück.
|
||||
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
|
||||
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
|
||||
// gemessen, nicht an der englischen.
|
||||
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
|
||||
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
|
||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||
import type { Darsteller } from '../../gemeinsam/nachrichten'
|
||||
|
||||
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' | ''
|
||||
// ── Kino-Banner v2 (5.4.0) ──
|
||||
logoPfad: string
|
||||
tagline: string
|
||||
bewertung: number
|
||||
fsk: string
|
||||
besetzung: Darsteller[]
|
||||
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
|
||||
folgenGesamt: number
|
||||
}
|
||||
|
||||
export interface Zuordnung {
|
||||
titel: string
|
||||
jahr: number | null
|
||||
confidence: number
|
||||
meta: MetaTreffer
|
||||
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
|
||||
hinweise?: string[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
||||
const laufzeit =
|
||||
typ === 'movie'
|
||||
? (details.runtime ?? 0)
|
||||
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
|
||||
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: laufzeit,
|
||||
genres: (details.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
logoPfad: logoAus(details.images?.logos),
|
||||
tagline: (details.tagline ?? '').trim(),
|
||||
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
|
||||
fsk: fskAus(details, typ),
|
||||
besetzung: besetzungAus(details.credits?.cast),
|
||||
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
logoPfad: '',
|
||||
tagline: '',
|
||||
bewertung: 0,
|
||||
fsk: '',
|
||||
besetzung: [],
|
||||
folgenGesamt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const LEERER_TREFFER: MetaTreffer = {
|
||||
typ: 'unknown',
|
||||
id: null,
|
||||
titel: '',
|
||||
jahr: null,
|
||||
beschreibung: '',
|
||||
posterPfad: '',
|
||||
backdropPfad: '',
|
||||
laufzeitMinuten: 0,
|
||||
genres: [],
|
||||
quelle: '',
|
||||
logoPfad: '',
|
||||
tagline: '',
|
||||
bewertung: 0,
|
||||
fsk: '',
|
||||
besetzung: [],
|
||||
folgenGesamt: 0,
|
||||
}
|
||||
|
||||
const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||
titel,
|
||||
jahr: null,
|
||||
confidence: 0.3,
|
||||
meta: { ...LEERER_TREFFER, titel },
|
||||
})
|
||||
|
||||
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
|
||||
* „Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
|
||||
* keine Bewertung — TMDb kennt dort nur den Namen. */
|
||||
export function istStub(details: FilmDetails): boolean {
|
||||
const beschreibung = (details.overview ?? '').trim().length > 0
|
||||
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
|
||||
const bewertet = (details.vote_count ?? 0) > 0
|
||||
return !beschreibung && !bild && !bewertet
|
||||
}
|
||||
|
||||
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||
function worte(t: string): Set<string> {
|
||||
return new Set(
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 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)
|
||||
}
|
||||
|
||||
/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung
|
||||
* weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb
|
||||
* schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of
|
||||
* the Arena' war zeichengenau verglichen kein Treffer — der exakt
|
||||
* richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */
|
||||
export function gleichBedeutend(a: string, b: string): boolean {
|
||||
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
||||
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
||||
const nackt = (t: string): string =>
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0)
|
||||
.join(' ')
|
||||
const na = nackt(a)
|
||||
return na.length > 0 && na === nackt(b)
|
||||
}
|
||||
|
||||
/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (0–1)? */
|
||||
export function abdeckung(discTitel: string, kandidat: string): number {
|
||||
const ganz = worte(discTitel)
|
||||
if (ganz.size === 0) return 0
|
||||
let drin = 0
|
||||
for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1
|
||||
return drin / ganz.size
|
||||
}
|
||||
|
||||
/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */
|
||||
export const VORSCHLAG = 0.6
|
||||
|
||||
/** Darunter ist ein Treffer GERATEN, nicht gefunden — und wird zum
|
||||
* Vorschlag herabgestuft, egal wie wörtlich er aussieht.
|
||||
*
|
||||
* Der Grund, an der echten Disc gemessen (01.09.2026): Aus
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die
|
||||
* Frage 'Spartacus' übrig — sechs von sieben Wörtern weggeworfen. TMDb
|
||||
* antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit
|
||||
* 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART
|
||||
* des Treffers, nie die QUALITÄT der Frage. */
|
||||
export const MINDEST_ABDECKUNG = 0.5
|
||||
|
||||
export interface DiscHinweis {
|
||||
/** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen
|
||||
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
|
||||
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
|
||||
istSerie?: boolean
|
||||
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
|
||||
alternativen?: readonly string[]
|
||||
}
|
||||
|
||||
interface Kandidat {
|
||||
text: string
|
||||
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
|
||||
massstab: string
|
||||
}
|
||||
|
||||
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
|
||||
* gestutzten — je Variante an ihrer eigenen Vorlage gemessen (pur). */
|
||||
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
|
||||
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
|
||||
const gesehen = new Set<string>()
|
||||
const voll: Kandidat[] = []
|
||||
const rest: Kandidat[] = []
|
||||
for (const quelle of quellen) {
|
||||
const massstab = discZusatzAbtrennen(quelle).titel || quelle
|
||||
const varianten = titelKandidaten(quelle)
|
||||
if (varianten.length === 0) varianten.push(quelle)
|
||||
varianten.forEach((text, i) => {
|
||||
const schluessel = text.toLowerCase()
|
||||
if (gesehen.has(schluessel)) return
|
||||
gesehen.add(schluessel)
|
||||
;(i === 0 ? voll : rest).push({ text, massstab })
|
||||
})
|
||||
}
|
||||
return [...voll, ...rest]
|
||||
}
|
||||
|
||||
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||
*
|
||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch
|
||||
* Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
|
||||
*
|
||||
* Drei Sicherungen, alle an echten Discs bezahlt:
|
||||
*
|
||||
* * `hinweis.istSerie` schaltet die Film-Zweige ganz ab.
|
||||
* * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG):
|
||||
* Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten —
|
||||
* das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026:
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage
|
||||
* 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet.
|
||||
* * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN
|
||||
* Kandidaten — er muss noch jedes Wort des Titels tragen (Zusätze wie
|
||||
* '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer
|
||||
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
|
||||
* Nummer 1 und fiel durch dieses Raster.
|
||||
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer — der
|
||||
* nächste wörtliche Treffer bekommt die Chance.
|
||||
*/
|
||||
export async function zuordnen(
|
||||
discTitel: string,
|
||||
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||
hinweis: DiscHinweis = {},
|
||||
): Promise<Zuordnung> {
|
||||
const titel = discTitel.trim()
|
||||
if (titel.length === 0) return UNBEKANNT('')
|
||||
|
||||
const nurSerie = hinweis.istSerie === true
|
||||
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
|
||||
|
||||
let ersteFilme: FilmTreffer[] = []
|
||||
let ersteSerien: FilmTreffer[] = []
|
||||
|
||||
for (const kandidat of kandidaten) {
|
||||
const deckung = abdeckung(kandidat.massstab, kandidat.text)
|
||||
const vollstaendig = deckung >= 1
|
||||
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
|
||||
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
|
||||
|
||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||
|
||||
// Wörtlicher Filmtreffer (0,95)
|
||||
for (const film of filme) {
|
||||
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||
|
||||
// Wörtlicher Serientreffer (0,90)
|
||||
for (const serie of serien) {
|
||||
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||
// (der Evangelion-Fund). Stubs zählen dabei nicht mit.
|
||||
if (vollstaendig) {
|
||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||
for (const film of filme) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
|
||||
for (const serie of serien) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details !== null && !istStub(details)) {
|
||||
const meta = ausDetails('tv', serie.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.text)
|
||||
if (treffer !== null) {
|
||||
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
|
||||
// was sie ist.
|
||||
if (nurSerie && treffer.type !== 'tv') continue
|
||||
// Ä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.text, 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'
|
||||
}
|
||||
}
|
||||
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
|
||||
// ist ein Vorschlag, egal von welchem Dienst er kommt.
|
||||
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
|
||||
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
|
||||
let notnagel: Zuordnung | null = null
|
||||
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details === null) continue
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
if (!istStub(details)) return vorschlag
|
||||
notnagel ??= vorschlag
|
||||
}
|
||||
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details === null) continue
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
if (!istStub(details)) return vorschlag
|
||||
notnagel ??= vorschlag
|
||||
}
|
||||
if (notnagel !== null) return notnagel
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
|
||||
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
|
||||
export const LAUFZEIT_PASST = 0.06
|
||||
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
|
||||
export const LAUFZEIT_FALSCH = 0.25
|
||||
|
||||
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
|
||||
export interface Messung {
|
||||
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
|
||||
hauptS: number
|
||||
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
|
||||
folgenS: readonly number[]
|
||||
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
|
||||
struktur: 'film' | 'serie' | 'unklar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
|
||||
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
|
||||
* haben ist — die Laufzeit. Aus „wahrscheinlich" wird „bestätigt", oder
|
||||
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
|
||||
* (confidence 1) wird nie herabgestuft, nur kommentiert.
|
||||
*/
|
||||
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
|
||||
const hinweise: string[] = []
|
||||
let confidence = zuordnung.confidence
|
||||
const meta = zuordnung.meta
|
||||
const vomNutzer = zuordnung.confidence >= 1
|
||||
const soll = meta.laufzeitMinuten * 60
|
||||
|
||||
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
|
||||
const abweichung = Math.abs(messung.hauptS - soll) / soll
|
||||
const ist = Math.round(messung.hauptS / 60)
|
||||
if (abweichung <= LAUFZEIT_PASST) {
|
||||
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
|
||||
} else if (abweichung >= LAUFZEIT_FALSCH) {
|
||||
hinweise.push(
|
||||
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
|
||||
)
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
} else {
|
||||
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
|
||||
}
|
||||
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
|
||||
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
|
||||
const abweichung = Math.abs(mittel - soll) / soll
|
||||
const ist = Math.round(mittel / 60)
|
||||
if (abweichung <= 0.2) {
|
||||
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
|
||||
} else if (abweichung >= 0.5) {
|
||||
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
}
|
||||
}
|
||||
|
||||
if (messung.struktur === 'serie' && meta.typ === 'movie') {
|
||||
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
|
||||
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
|
||||
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
|
||||
}
|
||||
|
||||
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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 }
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
// 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 { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdirSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
KRITISCHE_CODES,
|
||||
KRITISCHE_TEXTE,
|
||||
LESEFEHLER_MARKE,
|
||||
offsetAusMeldung,
|
||||
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]
|
||||
}
|
||||
|
||||
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||
* von einem echten Defekt zu unterscheiden. */
|
||||
export const HAENGT_AB = 12
|
||||
|
||||
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
|
||||
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
|
||||
export const ABGELOEST = 'titel-lauf-abgeloest'
|
||||
|
||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||
export class RipAuswertung {
|
||||
prozent = -1
|
||||
letzteMeldung = ''
|
||||
lesefehler = false
|
||||
kritischeMeldungen: string[] = []
|
||||
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||
haengtBei = ''
|
||||
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||
haengtWiederholungen = 0
|
||||
|
||||
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||
haengt(): boolean {
|
||||
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||
}
|
||||
|
||||
/** 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 {}
|
||||
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||
this.haengtBei = ''
|
||||
this.haengtWiederholungen = 0
|
||||
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
|
||||
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||
const stelle = offsetAusMeldung(meldung.text)
|
||||
if (stelle.length > 0) {
|
||||
if (stelle === this.haengtBei) {
|
||||
this.haengtWiederholungen += 1
|
||||
} else {
|
||||
this.haengtBei = stelle
|
||||
this.haengtWiederholungen = 1
|
||||
}
|
||||
}
|
||||
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 {
|
||||
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
|
||||
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
|
||||
*
|
||||
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
|
||||
* Programm konnte keine verwendbaren optischen Laufwerke finden")
|
||||
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
|
||||
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
|
||||
* dass das Laufwerk gemeint ist und nicht die Disc. */
|
||||
ursachen: string[]
|
||||
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,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<DiscInfo> {
|
||||
return new Promise((fertig, scheitert) => {
|
||||
const kind = werkzeugSpawn(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)
|
||||
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||
// die längst gelieferte Titelliste).
|
||||
const aufgeben = (): void => {
|
||||
clearTimeout(zeiger)
|
||||
kind.kill()
|
||||
scheitert(new Error(ABGELOEST))
|
||||
}
|
||||
abbruch?.addEventListener('abort', aufgeben, { once: true })
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
scheitert(fehler)
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||
const titel = parseTitelInfo(ausgabe)
|
||||
const streams = parseStreamInfo(ausgabe)
|
||||
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
|
||||
const ursachen: string[] = []
|
||||
for (const zeile of ausgabe.split('\n')) {
|
||||
const meldung = parseMsg(zeile)
|
||||
if (meldung === null) continue
|
||||
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
|
||||
ursachen.push(KRITISCHE_CODES[meldung.code])
|
||||
}
|
||||
}
|
||||
fertig({ ursachen, 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
|
||||
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
|
||||
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
|
||||
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => 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 = werkzeugSpawn(
|
||||
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 })
|
||||
|
||||
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
|
||||
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
|
||||
let klemmteVorher = false
|
||||
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)
|
||||
}
|
||||
const klemmt = auswertung.haengt()
|
||||
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
|
||||
klemmteVorher = klemmt
|
||||
optionen.aufHaengt?.(
|
||||
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
||||
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
||||
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
|
||||
export type { SprachEintrag }
|
||||
|
||||
/** 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') }
|
||||
}
|
||||
|
||||
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
||||
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
||||
* (Titel unbekannt) leer, nicht erfunden. */
|
||||
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
|
||||
audio: SprachEintrag[]
|
||||
untertitel: SprachEintrag[]
|
||||
} {
|
||||
if (streams === undefined) return { audio: [], untertitel: [] }
|
||||
return sprachenZusammenfassen(new Map([[0, streams]]))
|
||||
}
|
||||
|
||||
// ── 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'
|
||||
|
||||
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
|
||||
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
|
||||
*
|
||||
* … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
|
||||
* … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
|
||||
*
|
||||
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
|
||||
* — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
|
||||
*
|
||||
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
|
||||
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
|
||||
* Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
|
||||
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
|
||||
export function offsetAusMeldung(text: string): string {
|
||||
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
|
||||
// Anführungszeichen, ein Offset ist immer groß.
|
||||
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
|
||||
return treffer === null ? '' : treffer[1]
|
||||
}
|
||||
|
||||
// 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',
|
||||
]
|
||||