diff --git a/beweise/klick/01-uebersicht.png b/beweise/klick/01-uebersicht.png new file mode 100644 index 0000000..04f07f3 Binary files /dev/null and b/beweise/klick/01-uebersicht.png differ diff --git a/beweise/klick/02-dialog.png b/beweise/klick/02-dialog.png new file mode 100644 index 0000000..cb35ceb Binary files /dev/null and b/beweise/klick/02-dialog.png differ diff --git a/beweise/klick/03-fertig.png b/beweise/klick/03-fertig.png new file mode 100644 index 0000000..3432ab0 Binary files /dev/null and b/beweise/klick/03-fertig.png differ diff --git a/beweise/klick/04-roh-dateien.png b/beweise/klick/04-roh-dateien.png new file mode 100644 index 0000000..731e657 Binary files /dev/null and b/beweise/klick/04-roh-dateien.png differ diff --git a/beweise/klick/05-ereignisse.png b/beweise/klick/05-ereignisse.png new file mode 100644 index 0000000..ad9efb9 Binary files /dev/null and b/beweise/klick/05-ereignisse.png differ diff --git a/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png b/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png new file mode 100644 index 0000000..44a5d8a Binary files /dev/null and b/beweise/klick/fehler-einstellungen-roh-dateien-zeigt-den-vorgang-mit-belegung.png differ diff --git a/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png b/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png new file mode 100644 index 0000000..1ccd8a1 Binary files /dev/null and b/beweise/klick/fehler-rollen-auf-folge-staffel-folge-felder-erscheinen.png differ diff --git a/rippy-windows/BAUEN.md b/rippy-windows/BAUEN.md index a5fca40..ff59a19 100644 --- a/rippy-windows/BAUEN.md +++ b/rippy-windows/BAUEN.md @@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`. | `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=` 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. diff --git a/rippy-windows/bau/klick-smoke.mjs b/rippy-windows/bau/klick-smoke.mjs new file mode 100644 index 0000000..3e99328 --- /dev/null +++ b/rippy-windows/bau/klick-smoke.mjs @@ -0,0 +1,192 @@ +// 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) diff --git a/rippy-windows/bau/klick/fake-handbrake.cjs b/rippy-windows/bau/klick/fake-handbrake.cjs new file mode 100644 index 0000000..9ad611e --- /dev/null +++ b/rippy-windows/bau/klick/fake-handbrake.cjs @@ -0,0 +1,90 @@ +// 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 -o ') + out('') + out('Video Options -----------------------------------------------------------------') + out('') + out(' -e, --encoder Select video encoder:') + out(' x264') + out(' x265') + out(' nvenc_h264') + out(' -q, --quality 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) diff --git a/rippy-windows/bau/klick/fake-makemkvcon.cjs b/rippy-windows/bau/klick/fake-makemkvcon.cjs new file mode 100644 index 0000000..6301f94 --- /dev/null +++ b/rippy-windows/bau/klick/fake-makemkvcon.cjs @@ -0,0 +1,79 @@ +// 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: → 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) +} diff --git a/rippy-windows/electron.vite.config.ts b/rippy-windows/electron.vite.config.ts index 7873cab..e3e86e6 100644 --- a/rippy-windows/electron.vite.config.ts +++ b/rippy-windows/electron.vite.config.ts @@ -25,8 +25,11 @@ function cspNurImBau(): Plugin { return html.replace( '', // img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst - // bleibt alles bei 'self'. - `\n `, + // 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. + `\n `, ) }, } diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 2dcf95b..afe023e 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -13,6 +13,7 @@ "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" }, diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index f1e3f8a..b77f573 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -571,7 +571,7 @@ function TitelWahlDialog({ ersteFolge: folgeZahl, hauptTitel, extras, - vorgangsName: `${typ}-`, + vorgangsName: `${typ}-`, }) return ( diff --git a/rippy-windows/src/haupt/index.ts b/rippy-windows/src/haupt/index.ts index 792ea1d..c802159 100644 --- a/rippy-windows/src/haupt/index.ts +++ b/rippy-windows/src/haupt/index.ts @@ -21,14 +21,25 @@ 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. +// 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 { @@ -77,7 +88,7 @@ function start(): void { } else if (nachricht.art === 'einstellungen') { // Einmalig das Selbst-Update einrichten — die Adresse kommt aus // der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8). - if (!updateGestartet && !smokeModus) { + if (!updateGestartet && !ohneNebenwirkungen) { updateGestartet = true updateEinrichten( nachricht.werte['updateUrl'] ?? '', @@ -98,9 +109,9 @@ function start(): void { // 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' && !smokeModus) { + } 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 && !smokeModus) { + } 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', @@ -162,7 +173,7 @@ function start(): void { } letzterRip = status anzeigeAktualisieren() - if (smokeModus) return + if (ohneNebenwirkungen) return if (status.phase === 'fertig') { toast('Rippy: Rip fertig', status.text) } else if (status.phase === 'fehler') { @@ -333,7 +344,7 @@ function start(): void { void app.whenReady().then(() => { kernVerwaltung.starten() - if (!smokeModus) { + if (!ohneNebenwirkungen) { trayErstellen(fensterZeigen, beenden) } fenster = fensterErstellen() diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 7ae6c6a..7c5f1c3 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -29,7 +29,7 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' import { isoSichern } from '../rip/iso' import { rippen } from '../rip/makemkv' -import { spawn } from 'node:child_process' +import { werkzeugSpawn } from '../werkzeuge/aufruf' import { mkdtempSync, createWriteStream } from 'node:fs' import { tmpdir } from 'node:os' import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag' @@ -605,7 +605,7 @@ export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet { return new Promise((fertig) => { - const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + 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) })) diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 8d6f9a8..3f864ee 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -20,10 +20,12 @@ import { istHauptNachricht, type DbStatus, type DiscInfoStand, + type GeraeteInfo, type KernNachricht, type MetaVorschlag, type VorgaengeStand, type VorgangEintrag, + type WacheEreignis, } from '../gemeinsam/nachrichten' import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen' import { auftragAusJson, auftragZuJson } from './ablauf/auftrag' @@ -260,8 +262,54 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise = (async () => { try { + if (FAKE_LAUFWERK.length > 0) { + wache = new DiscWache(() => [fakeGeraet()], wacheMeldung, wacheFehler) + wache.starten() + return + } if (process.platform !== 'win32') { throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).') } @@ -269,25 +317,8 @@ const laufwerkeBereit: Promise = (async () => { laufwerkApi = win32Api() wache = new DiscWache( () => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)), - (meldung) => { - anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse }) - for (const ereignis of meldung.ereignisse) { - if (ereignis.art === 'disc-eingelegt') { - void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes) - } else if (ereignis.art === 'disc-entnommen') { - discZuordnungen.delete(ereignis.geraet.id) - discFingerprints.delete(ereignis.geraet.id) - discMerkmale.delete(ereignis.geraet.id) - anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) }) - } - } - }, - (fehler) => { - // R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte - // Stand im Fenster bleibt stehen (R2), die Wache läuft weiter. - console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`) - anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` }) - }, + wacheMeldung, + wacheFehler, ) wache.starten() } catch (fehler) { @@ -296,6 +327,14 @@ const laufwerkeBereit: Promise = (async () => { } })() +/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */ +function geraetInfoVon(id: string): GeraeteInfo | null { + if (FAKE_LAUFWERK.length > 0) return id === FAKE_LAUFWERK ? fakeGeraet() : null + const geraet = geraetVon(id) + if (geraet === null || laufwerkApi === null) return null + return geraeteInfo(laufwerkApi, geraet) +} + // ── Titel der Disc lesen (§ 6.2) ──────────────────────────────────────── // Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und // nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr @@ -316,12 +355,12 @@ async function titelLesen(id: string): Promise { anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } }) return } - const geraet = geraetVon(id) - if (geraet === null || laufwerkApi === null) { + const geraet = geraetInfoVon(id) + if (geraet === null) { anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } }) return } - const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad + const geraetepfad = geraet.pfad const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') if (programm.length === 0) { anFenster({ @@ -751,11 +790,7 @@ const ripVerwaltung = new Pipeline({ if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt) }, - geraet: (id) => { - if (laufwerkApi === null) return null - const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) - return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet) - }, + geraet: geraetInfoVon, ablage: ablageOrdner, einstellungen: einstellungenLaden, melden: (status) => { diff --git a/rippy-windows/src/kern/komprimieren/encoder.ts b/rippy-windows/src/kern/komprimieren/encoder.ts index d0de57c..4109a85 100644 --- a/rippy-windows/src/kern/komprimieren/encoder.ts +++ b/rippy-windows/src/kern/komprimieren/encoder.ts @@ -7,7 +7,7 @@ // Einrückung — Namen stehen mit genau vier Leerzeichen). // - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst // läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT). -import { spawn } from 'node:child_process' +import { werkzeugSpawn } from '../werkzeuge/aufruf' import { textVon } from '../rip/parser' const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/ @@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] { function lauf(programm: string, args: string[], timeoutMs: number): Promise { return new Promise((fertig) => { - const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + 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)) diff --git a/rippy-windows/src/kern/komprimieren/handbrake.ts b/rippy-windows/src/kern/komprimieren/handbrake.ts index 5e6dc0a..e554b12 100644 --- a/rippy-windows/src/kern/komprimieren/handbrake.ts +++ b/rippy-windows/src/kern/komprimieren/handbrake.ts @@ -15,7 +15,7 @@ // im Fehlerfall die einzige Auskunft. // - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung — // sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus. -import { spawn } from 'node:child_process' +import { 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' @@ -253,7 +253,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise { return new Promise((fertig) => { const args = buildHandbrakeArgs(optionen) args.push(...(optionen.extraArgs ?? [])) - const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const auswertung = new HbAuswertung() const leser = new HbZeilenLeser() diff --git a/rippy-windows/src/kern/komprimieren/scan.ts b/rippy-windows/src/kern/komprimieren/scan.ts index 164f0c9..fa1d804 100644 --- a/rippy-windows/src/kern/komprimieren/scan.ts +++ b/rippy-windows/src/kern/komprimieren/scan.ts @@ -15,7 +15,7 @@ // 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 { spawn } from 'node:child_process' +import { werkzeugSpawn } from '../werkzeuge/aufruf' import { textVon } from '../rip/parser' export interface HbSpuren { @@ -87,7 +87,7 @@ export function spurenScannen(programm: string, datei: string, timeoutMs = 120_0 fertig(null) return } - const kind = spawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + 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)) diff --git a/rippy-windows/src/kern/rip/makemkv.ts b/rippy-windows/src/kern/rip/makemkv.ts index 7b48db5..42ebd7a 100644 --- a/rippy-windows/src/kern/rip/makemkv.ts +++ b/rippy-windows/src/kern/rip/makemkv.ts @@ -11,7 +11,7 @@ // sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2). // - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer // nur „Failed to open disc", die Ursache steht Zeilen davor. -import { spawn } from 'node:child_process' +import { werkzeugSpawn } from '../werkzeuge/aufruf' import { mkdirSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { @@ -160,7 +160,7 @@ export function titelInfoLesen( abbruch?: AbortSignal, ): Promise { return new Promise((fertig, scheitert) => { - const kind = spawn(programm, buildInfoArgs(geraetepfad), { + const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }) @@ -231,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise { return new Promise((fertig) => { mkdirSync(optionen.zielOrdner, { recursive: true }) - const kind = spawn( + const kind = werkzeugSpawn( optionen.programm, buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, diff --git a/rippy-windows/src/kern/werkzeuge/aufruf.ts b/rippy-windows/src/kern/werkzeuge/aufruf.ts new file mode 100644 index 0000000..e8a0805 --- /dev/null +++ b/rippy-windows/src/kern/werkzeuge/aufruf.ts @@ -0,0 +1,37 @@ +// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht +// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte +// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene +// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment +// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm +// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake +// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts. +// +// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12, +// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären +// Pfade mit Leerzeichen wieder eine Falle. +import { spawn } from 'node:child_process' + +export function istSkript(programm: string): boolean { + const p = programm.toLowerCase() + return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js') +} + +/** Programm + Argumente, so wie spawn sie bekommen soll. */ +export function werkzeugStart( + programm: string, + args: readonly string[], + umgebung: NodeJS.ProcessEnv = process.env, +): { programm: string; args: string[]; env: NodeJS.ProcessEnv } { + if (!istSkript(programm)) return { programm, args: [...args], env: umgebung } + return { + programm: process.execPath, + args: [programm, ...args], + env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' }, + } +} + +/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */ +export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record) => { + const start = werkzeugStart(programm, args) + return spawn(start.programm, start.args, { ...optionen, env: start.env }) +}) as typeof spawn diff --git a/rippy-windows/test/aufruf.test.ts b/rippy-windows/test/aufruf.test.ts new file mode 100644 index 0000000..f8e4b61 --- /dev/null +++ b/rippy-windows/test/aufruf.test.ts @@ -0,0 +1,31 @@ +// Die Werkzeug-Naht (kern/werkzeuge/aufruf.ts, 5.4.0): echte EXE-Pfade +// gehen unverändert durch; Node-Skripte (die Nachbauten des Klick-Beweises) +// laufen über die eigene Laufzeit mit ELECTRON_RUN_AS_NODE. +import { describe, expect, it } from 'vitest' +import { istSkript, werkzeugStart } from '../src/kern/werkzeuge/aufruf' + +describe('werkzeugStart', () => { + it('eine EXE bleibt eine EXE — Argumente und Umgebung unverändert', () => { + const start = werkzeugStart('C:\\Programme\\MakeMKV\\makemkvcon.exe', ['-r', 'info', 'dev:G:'], { PATH: 'x' }) + expect(start.programm).toBe('C:\\Programme\\MakeMKV\\makemkvcon.exe') + expect(start.args).toEqual(['-r', 'info', 'dev:G:']) + expect(start.env).toEqual({ PATH: 'x' }) + }) + + it('ein Skript läuft über die eigene Laufzeit, das Skript wird erstes Argument', () => { + const start = werkzeugStart('F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', ['-r', 'info'], { PATH: 'x' }) + expect(start.programm).toBe(process.execPath) + expect(start.args).toEqual(['F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', '-r', 'info']) + expect(start.env['ELECTRON_RUN_AS_NODE']).toBe('1') + expect(start.env['PATH']).toBe('x') + }) + + it('istSkript kennt .cjs/.mjs/.js — und sonst nichts', () => { + expect(istSkript('a.cjs')).toBe(true) + expect(istSkript('A.MJS')).toBe(true) + expect(istSkript('a.js')).toBe(true) + expect(istSkript('HandBrakeCLI.exe')).toBe(false) + expect(istSkript('a.json')).toBe(false) + expect(istSkript('')).toBe(false) + }) +}) diff --git a/rippy-windows/test/messung.spuren.test.ts b/rippy-windows/test/messung.spuren.test.ts new file mode 100644 index 0000000..18b1459 --- /dev/null +++ b/rippy-windows/test/messung.spuren.test.ts @@ -0,0 +1,31 @@ +// MESSUNG an einer ECHTEN Roh-Datei: Welche Ton- und Untertitelspuren hat +// MakeMKV wirklich in die MKV geschrieben? (5.4.0, Regel D) +// +// RIPPY_MESSUNG_MKV="E:\Rippy\roh\bluray-…\title_t00.mkv" npx vitest run test/messung.spuren.test.ts --reporter=verbose +// +// Der Anlass: Der Auswahl-Dialog zeigt die Sprachen, die der Info-Lauf an +// der DISC gemessen hat. Ob sie alle in der Datei landen, entscheidet +// MakeMKVs Auswahlregel (werkzeuge/spurauswahl.ts). Diese Messung fragt die +// Datei selbst — über denselben HandBrake-Scan, den die Kompression benutzt +// (komprimieren/scan.ts). Läuft nur auf Verlangen und nur unter Windows. +import { describe, expect, it } from 'vitest' + +const datei = process.env.RIPPY_MESSUNG_MKV ?? '' +const messen = datei.length > 0 && process.platform === 'win32' + +describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => { + it('HandBrake-Scan liefert Ton- und Untertitelspuren mit Sprachcode', { timeout: 180_000 }, async () => { + const { finden } = await import('../src/kern/werkzeuge/katalog') + const { spurenScannen } = await import('../src/kern/komprimieren/scan') + const handbrake = finden('handbrake', '') + expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0) + const start = Date.now() + const spuren = await spurenScannen(handbrake, datei) + console.log(`MESSUNG Scan: ${((Date.now() - start) / 1000).toFixed(1)} s — ${datei}`) + expect(spuren, 'Scan lieferte nichts').not.toBeNull() + console.log(`MESSUNG Dauer: ${Math.round(spuren!.dauerS / 60)} min`) + for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang} — ${a.beschreibung}`) + for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang} — ${u.beschreibung}`) + expect(spuren!.audio.length).toBeGreaterThan(0) + }) +})