feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK) und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange → Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 → Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit (ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts. WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests sahen keinen. Der Klick-Beweis haette beide gefunden. NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src. Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei auf Verlangen (RIPPY_MESSUNG_MKV). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
|
|||||||
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
|
||||||
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
|
| `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 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
|
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
|
||||||
fasst die echten Einstellungen nicht an.
|
fasst die echten Einstellungen nicht an.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 <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)
|
||||||
@@ -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: <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)
|
||||||
|
}
|
||||||
@@ -25,8 +25,11 @@ function cspNurImBau(): Plugin {
|
|||||||
return html.replace(
|
return html.replace(
|
||||||
'<head>',
|
'<head>',
|
||||||
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
|
||||||
// bleibt alles bei 'self'.
|
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
|
||||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
|
// 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:" />`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"smoke": "electron-vite build && electron . --smoke",
|
"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",
|
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||||
"werkzeug:electron": "node node_modules/electron/install.js"
|
"werkzeug:electron": "node node_modules/electron/install.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ function TitelWahlDialog({
|
|||||||
ersteFolge: folgeZahl,
|
ersteFolge: folgeZahl,
|
||||||
hauptTitel,
|
hauptTitel,
|
||||||
extras,
|
extras,
|
||||||
vorgangsName: `${typ}-<Zeit>`,
|
vorgangsName: `${typ}-<Zeitstempel>`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,14 +21,25 @@ import { trayErstellen, trayHinweis } from './tray'
|
|||||||
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||||
|
|
||||||
const smokeModus = process.argv.includes('--smoke')
|
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.
|
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
|
||||||
if (smokeModus) {
|
if (smokeModus) {
|
||||||
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
|
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
|
// 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()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -77,7 +88,7 @@ function start(): void {
|
|||||||
} else if (nachricht.art === 'einstellungen') {
|
} else if (nachricht.art === 'einstellungen') {
|
||||||
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
|
||||||
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
|
||||||
if (!updateGestartet && !smokeModus) {
|
if (!updateGestartet && !ohneNebenwirkungen) {
|
||||||
updateGestartet = true
|
updateGestartet = true
|
||||||
updateEinrichten(
|
updateEinrichten(
|
||||||
nachricht.werte['updateUrl'] ?? '',
|
nachricht.werte['updateUrl'] ?? '',
|
||||||
@@ -98,9 +109,9 @@ function start(): void {
|
|||||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
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}`)
|
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.
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||||
toast(
|
toast(
|
||||||
'Disc erkannt',
|
'Disc erkannt',
|
||||||
@@ -162,7 +173,7 @@ function start(): void {
|
|||||||
}
|
}
|
||||||
letzterRip = status
|
letzterRip = status
|
||||||
anzeigeAktualisieren()
|
anzeigeAktualisieren()
|
||||||
if (smokeModus) return
|
if (ohneNebenwirkungen) return
|
||||||
if (status.phase === 'fertig') {
|
if (status.phase === 'fertig') {
|
||||||
toast('Rippy: Rip fertig', status.text)
|
toast('Rippy: Rip fertig', status.text)
|
||||||
} else if (status.phase === 'fehler') {
|
} else if (status.phase === 'fehler') {
|
||||||
@@ -333,7 +344,7 @@ function start(): void {
|
|||||||
|
|
||||||
void app.whenReady().then(() => {
|
void app.whenReady().then(() => {
|
||||||
kernVerwaltung.starten()
|
kernVerwaltung.starten()
|
||||||
if (!smokeModus) {
|
if (!ohneNebenwirkungen) {
|
||||||
trayErstellen(fensterZeigen, beenden)
|
trayErstellen(fensterZeigen, beenden)
|
||||||
}
|
}
|
||||||
fenster = fensterErstellen()
|
fenster = fensterErstellen()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate
|
|||||||
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||||
import { isoSichern } from '../rip/iso'
|
import { isoSichern } from '../rip/iso'
|
||||||
import { rippen } from '../rip/makemkv'
|
import { rippen } from '../rip/makemkv'
|
||||||
import { spawn } from 'node:child_process'
|
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||||
import { mkdtempSync, createWriteStream } from 'node:fs'
|
import { mkdtempSync, createWriteStream } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||||
@@ -605,7 +605,7 @@ export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string
|
|||||||
|
|
||||||
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||||
return new Promise((fertig) => {
|
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[] = []
|
const stuecke: Buffer[] = []
|
||||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
|
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import {
|
|||||||
istHauptNachricht,
|
istHauptNachricht,
|
||||||
type DbStatus,
|
type DbStatus,
|
||||||
type DiscInfoStand,
|
type DiscInfoStand,
|
||||||
|
type GeraeteInfo,
|
||||||
type KernNachricht,
|
type KernNachricht,
|
||||||
type MetaVorschlag,
|
type MetaVorschlag,
|
||||||
type VorgaengeStand,
|
type VorgaengeStand,
|
||||||
type VorgangEintrag,
|
type VorgangEintrag,
|
||||||
|
type WacheEreignis,
|
||||||
} from '../gemeinsam/nachrichten'
|
} from '../gemeinsam/nachrichten'
|
||||||
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
|
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
|
||||||
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
|
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
|
||||||
@@ -260,8 +262,54 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
|
||||||
|
// Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann nichts,
|
||||||
|
// und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts). Leer im
|
||||||
|
// normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg.
|
||||||
|
const FAKE_LAUFWERK = (process.env['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
|
||||||
|
|
||||||
|
function fakeGeraet(): GeraeteInfo {
|
||||||
|
return {
|
||||||
|
id: FAKE_LAUFWERK,
|
||||||
|
name: `Laufwerk ${FAKE_LAUFWERK}:`,
|
||||||
|
typ: 'bluray',
|
||||||
|
pfad: `\\\\.\\${FAKE_LAUFWERK}:`,
|
||||||
|
status: 'ready',
|
||||||
|
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
|
||||||
|
seriennummer: 'KLICK',
|
||||||
|
groesseBytes: 33_759_100_928,
|
||||||
|
grund: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wacheFehler(fehler: unknown): void {
|
||||||
|
// 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)}` })
|
||||||
|
}
|
||||||
|
|
||||||
const laufwerkeBereit: Promise<void> = (async () => {
|
const laufwerkeBereit: Promise<void> = (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (FAKE_LAUFWERK.length > 0) {
|
||||||
|
wache = new DiscWache(() => [fakeGeraet()], wacheMeldung, wacheFehler)
|
||||||
|
wache.starten()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||||
}
|
}
|
||||||
@@ -269,25 +317,8 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
laufwerkApi = win32Api()
|
laufwerkApi = win32Api()
|
||||||
wache = new DiscWache(
|
wache = new DiscWache(
|
||||||
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||||
(meldung) => {
|
wacheMeldung,
|
||||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
wacheFehler,
|
||||||
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)}` })
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
wache.starten()
|
wache.starten()
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -296,6 +327,14 @@ const laufwerkeBereit: Promise<void> = (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) ────────────────────────────────────────
|
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
// 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
|
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||||
@@ -316,12 +355,12 @@ async function titelLesen(id: string): Promise<void> {
|
|||||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
|
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const geraet = geraetVon(id)
|
const geraet = geraetInfoVon(id)
|
||||||
if (geraet === null || laufwerkApi === null) {
|
if (geraet === null) {
|
||||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
|
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad
|
const geraetepfad = geraet.pfad
|
||||||
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||||
if (programm.length === 0) {
|
if (programm.length === 0) {
|
||||||
anFenster({
|
anFenster({
|
||||||
@@ -751,11 +790,7 @@ const ripVerwaltung = new Pipeline({
|
|||||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||||
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
||||||
},
|
},
|
||||||
geraet: (id) => {
|
geraet: geraetInfoVon,
|
||||||
if (laufwerkApi === null) return null
|
|
||||||
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
|
||||||
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
|
||||||
},
|
|
||||||
ablage: ablageOrdner,
|
ablage: ablageOrdner,
|
||||||
einstellungen: einstellungenLaden,
|
einstellungen: einstellungenLaden,
|
||||||
melden: (status) => {
|
melden: (status) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
||||||
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
||||||
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
|
// 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'
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
|
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<string> {
|
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
|
||||||
return new Promise((fertig) => {
|
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[] = []
|
const stuecke: Buffer[] = []
|
||||||
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// im Fehlerfall die einzige Auskunft.
|
// im Fehlerfall die einzige Auskunft.
|
||||||
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||||
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
// 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'
|
import { existsSync } from 'node:fs'
|
||||||
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||||
import { win32 as pfad } from 'node:path'
|
import { win32 as pfad } from 'node:path'
|
||||||
@@ -253,7 +253,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
|||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
const args = buildHandbrakeArgs(optionen)
|
const args = buildHandbrakeArgs(optionen)
|
||||||
args.push(...(optionen.extraArgs ?? []))
|
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 auswertung = new HbAuswertung()
|
||||||
const leser = new HbZeilenLeser()
|
const leser = new HbZeilenLeser()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// Objekt endet also NICHT am Textende — es wird über die Klammern
|
// Objekt endet also NICHT am Textende — es wird über die Klammern
|
||||||
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
|
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
|
||||||
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
|
// 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'
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
export interface HbSpuren {
|
export interface HbSpuren {
|
||||||
@@ -87,7 +87,7 @@ export function spurenScannen(programm: string, datei: string, timeoutMs = 120_0
|
|||||||
fertig(null)
|
fertig(null)
|
||||||
return
|
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[] = []
|
const stuecke: Buffer[] = []
|
||||||
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
||||||
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
||||||
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
// 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 { mkdirSync, readdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
@@ -160,7 +160,7 @@ export function titelInfoLesen(
|
|||||||
abbruch?: AbortSignal,
|
abbruch?: AbortSignal,
|
||||||
): Promise<DiscInfo> {
|
): Promise<DiscInfo> {
|
||||||
return new Promise((fertig, scheitert) => {
|
return new Promise((fertig, scheitert) => {
|
||||||
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
@@ -231,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
|||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
mkdirSync(optionen.zielOrdner, { recursive: true })
|
mkdirSync(optionen.zielOrdner, { recursive: true })
|
||||||
|
|
||||||
const kind = spawn(
|
const kind = werkzeugSpawn(
|
||||||
optionen.programm,
|
optionen.programm,
|
||||||
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
||||||
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
|||||||
@@ -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<string, unknown>) => {
|
||||||
|
const start = werkzeugStart(programm, args)
|
||||||
|
return spawn(start.programm, start.args, { ...optionen, env: start.env })
|
||||||
|
}) as typeof spawn
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||