fix(v5): kein stilles Scheitern mehr — Rip-Start meldet unerreichbare Ablage, Kern-Neustart verdrahtet das Fenster neu, eine Wache-Kette je Laufwerk, Rettungs-Abbild überlebt, Titel-Auftrag schlägt „unbekannt"
WAS: - pipeline.starten fängt jede Ausnahme und meldet sie als Fehler des Laufwerks; vor dem Start eine Schreibprobe am Zielordner mit Klartext („nicht erreichbar … NAS verbunden?"). Vorher warf mkdirSync bei toter Ablage aus starten() heraus, der Aufrufer schluckte es: kein Fehler im Fenster, keine Protokollzeile, der Dialog ging zu (Sonde 10). - kern/index.ts: unbehandelte Promise-Ablehnungen landen im Protokoll und im Fenster (gemessen: ein Electron-utilityProcess stirbt daran nicht, er schweigt); echte Ausnahmen werden protokolliert, der Kern endet kontrolliert, der Haupt startet ihn neu. Die Einlege-Kette hat ein Fangnetz. - kernstart.ts spannt nach einem Kern-Neustart den Fenster-Port neu auf; das Fenster schließt den toten Port und meldet den Neustart als Zeile. Vorher blieb es taub, bis Rippy komplett neu gestartet wurde. - wache.runde() löscht den geplanten Timer, bevor es einen neuen setzt — jeder Auswurf legte sonst eine weitere 3-s-Kette an (Sonde 3). - beenden() räumt den leeren Roh-Ordner, verschont aber rettung.iso und den Bericht (RETTUNG_DATEIEN). - Typ „unbekannt" heißt nur ohne Titel-Auftrag ISO; mit Auftrag rippt makemkvcon, der Typ folgt der Größe (Blu-ray/DVD) für Preset und Ordner. WARUM: Durchsicht 12.09.2026, Funde F2, F4, F6, F8, F10 — jeder mit Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
12b9720386
commit
8c4456d005
@@ -131,6 +131,11 @@ function meldung(text: string): void {
|
|||||||
function kernNachricht(nachricht: KernNachricht): void {
|
function kernNachricht(nachricht: KernNachricht): void {
|
||||||
switch (nachricht.art) {
|
switch (nachricht.art) {
|
||||||
case 'kern-bereit':
|
case 'kern-bereit':
|
||||||
|
// 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das
|
||||||
|
// steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren.
|
||||||
|
if (stand.kern !== null && stand.kern.pid !== nachricht.pid) {
|
||||||
|
meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`)
|
||||||
|
}
|
||||||
stand.kern = nachricht
|
stand.kern = nachricht
|
||||||
break
|
break
|
||||||
case 'pong':
|
case 'pong':
|
||||||
@@ -266,6 +271,15 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function portAnnehmen(neuerPort: MessagePort): void {
|
function portAnnehmen(neuerPort: MessagePort): void {
|
||||||
|
// Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte
|
||||||
|
// ist tot und wird geschlossen, damit nichts mehr ins Leere geht.
|
||||||
|
if (port !== null && port !== neuerPort) {
|
||||||
|
try {
|
||||||
|
port.close()
|
||||||
|
} catch {
|
||||||
|
// Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg.
|
||||||
|
}
|
||||||
|
}
|
||||||
port = neuerPort
|
port = neuerPort
|
||||||
stand.verbunden = true
|
stand.verbunden = true
|
||||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export class KernVerwaltung {
|
|||||||
private beendenGewollt = false
|
private beendenGewollt = false
|
||||||
private neustarts = 0
|
private neustarts = 0
|
||||||
private neustartZeiten: number[] = []
|
private neustartZeiten: number[] = []
|
||||||
|
/** Das Fenster, das zuletzt einen Port bekam — ein NEU gestarteter Kern
|
||||||
|
* bekommt es wieder (5.7.0, Durchsicht F4: vorher hielt das Fenster nach
|
||||||
|
* einem Kern-Neustart den toten Port und war taub, bis Rippy komplett neu
|
||||||
|
* gestartet wurde). */
|
||||||
|
private letztesFenster: WebContents | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly datenbankPfad: string,
|
private readonly datenbankPfad: string,
|
||||||
@@ -54,6 +59,11 @@ export class KernVerwaltung {
|
|||||||
|
|
||||||
kern.on('spawn', () => {
|
kern.on('spawn', () => {
|
||||||
this.beiZustand(this.zustand())
|
this.beiZustand(this.zustand())
|
||||||
|
// Ein Neustart: Das Fenster hängt noch am toten Port des alten Kerns —
|
||||||
|
// frischen Port aufspannen, sonst tut kein Knopf mehr etwas.
|
||||||
|
if (this.neustarts > 0 && this.letztesFenster !== null && !this.letztesFenster.isDestroyed()) {
|
||||||
|
this.fensterVerbinden(this.letztesFenster)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
kern.on('exit', (code) => {
|
kern.on('exit', (code) => {
|
||||||
@@ -80,6 +90,7 @@ export class KernVerwaltung {
|
|||||||
* ins Fenster (§ 4.1).
|
* ins Fenster (§ 4.1).
|
||||||
*/
|
*/
|
||||||
fensterVerbinden(webContents: WebContents): void {
|
fensterVerbinden(webContents: WebContents): void {
|
||||||
|
this.letztesFenster = webContents
|
||||||
if (this.kern === null) return
|
if (this.kern === null) return
|
||||||
const { port1, port2 } = new MessageChannelMain()
|
const { port1, port2 } = new MessageChannelMain()
|
||||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
||||||
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
||||||
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
||||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type {
|
import type {
|
||||||
BibliothekEintrag,
|
BibliothekEintrag,
|
||||||
|
DiscTyp,
|
||||||
GeraeteInfo,
|
GeraeteInfo,
|
||||||
RipAuftragOptionen,
|
RipAuftragOptionen,
|
||||||
RipAuftragTitel,
|
RipAuftragTitel,
|
||||||
@@ -39,6 +40,7 @@ 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'
|
||||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
||||||
|
import { BLURAY_MIN_BYTES } from '../laufwerk/disc'
|
||||||
|
|
||||||
interface LaufenderVorgang {
|
interface LaufenderVorgang {
|
||||||
/** Beendet den GANZEN Auftrag. */
|
/** Beendet den GANZEN Auftrag. */
|
||||||
@@ -114,6 +116,11 @@ export interface PipelineUmgebung {
|
|||||||
rettungsAbbild?: typeof rettungsAbbildDatei
|
rettungsAbbild?: typeof rettungsAbbildDatei
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Rettungs-Abbild und sein Bericht — die zwei Dateien im Roh-Ordner,
|
||||||
|
* die KEIN Aufräumen anfasst (5.7.0, Durchsicht F8). */
|
||||||
|
export const RETTUNG_ISO = 'rettung.iso'
|
||||||
|
export const RETTUNG_DATEIEN: readonly string[] = [RETTUNG_ISO, `${RETTUNG_ISO}.rettung.txt`]
|
||||||
|
|
||||||
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
||||||
interface LetzterAuftrag {
|
interface LetzterAuftrag {
|
||||||
auftrag: RipAuftragTitel[]
|
auftrag: RipAuftragTitel[]
|
||||||
@@ -180,12 +187,52 @@ export class Pipeline {
|
|||||||
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher).
|
||||||
|
*
|
||||||
|
* 5.7.0 (Durchsicht 12.09.2026, F2): Löst NIE mit einer Ausnahme auf.
|
||||||
|
* Vorher warf `mkdirSync` bei nicht erreichbarer Ablage aus dieser
|
||||||
|
* Funktion heraus, der Aufrufer (`void … .then(() => starten())`)
|
||||||
|
* verschluckte das — kein Fehler im Fenster, keine Zeile im Protokoll,
|
||||||
|
* der Dialog ging zu, nichts passierte (Sonde 10). Jetzt wird jede
|
||||||
|
* Ausnahme zur Fehler-Meldung des Laufwerks (R4). */
|
||||||
async starten(
|
async starten(
|
||||||
id: string,
|
id: string,
|
||||||
auftrag: readonly RipAuftragTitel[] = [],
|
auftrag: readonly RipAuftragTitel[] = [],
|
||||||
optionen: RipAuftragOptionen = {},
|
optionen: RipAuftragOptionen = {},
|
||||||
rettung = false,
|
rettung = false,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.lauf(id, auftrag, optionen, rettung)
|
||||||
|
} catch (fehler) {
|
||||||
|
const vorgangId = this.laufend.get(id)?.status.vorgangId
|
||||||
|
this.laufend.delete(id)
|
||||||
|
const text = `Rip auf Laufwerk ${id} abgebrochen — unerwarteter Fehler: ${String(fehler).slice(0, 300)}`
|
||||||
|
if (vorgangId !== undefined) this.umgebung.vorgangEndet?.(vorgangId, 'fehler', text)
|
||||||
|
this.umgebung.melden({ ...this.fehler(id, text), ...(vorgangId !== undefined ? { vorgangId } : {}) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist dieser Ordner da und beschreibbar? Eine Ablage auf einer NAS oder
|
||||||
|
* einem Netzlaufwerk ist beim Start nicht selbstverständlich erreichbar —
|
||||||
|
* ausprobieren statt annehmen (Regel aus AGENTS: Wo eine Eigenschaft
|
||||||
|
* ausprobierbar ist, probiere sie aus). '' wenn alles gut, sonst der Grund. */
|
||||||
|
static schreibprobe(ordner: string): string {
|
||||||
|
try {
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const probe = join(ordner, `.rippy-schreibprobe-${process.pid}`)
|
||||||
|
writeFileSync(probe, 'rippy')
|
||||||
|
unlinkSync(probe)
|
||||||
|
return ''
|
||||||
|
} catch (fehler) {
|
||||||
|
return String(fehler instanceof Error ? fehler.message : fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lauf(
|
||||||
|
id: string,
|
||||||
|
auftrag: readonly RipAuftragTitel[],
|
||||||
|
optionen: RipAuftragOptionen,
|
||||||
|
rettung: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.laufend.has(id)) {
|
if (this.laufend.has(id)) {
|
||||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
@@ -226,6 +273,21 @@ export class Pipeline {
|
|||||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||||
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
||||||
|
|
||||||
|
// 5.7.0 (F2): Erst nachsehen, ob das Ziel überhaupt beschreibbar ist —
|
||||||
|
// eine NAS, die gerade weg ist, soll einen Satz in der Kachel
|
||||||
|
// hinterlassen, nicht ein stilles Nichts.
|
||||||
|
const zielWurzel = geraet.typ === 'cd' || geraet.typ === 'unknown' ? this.umgebung.ablage() : (this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'))
|
||||||
|
const probe = Pipeline.schreibprobe(zielWurzel)
|
||||||
|
if (probe.length > 0) {
|
||||||
|
this.umgebung.melden(
|
||||||
|
this.fehler(
|
||||||
|
id,
|
||||||
|
`Rip nicht gestartet: Der Ordner ${zielWurzel} ist nicht erreichbar oder nicht beschreibbar (${probe}). Ist das Netzlaufwerk bzw. die NAS verbunden? Einstellungen → Ablage zeigt die Pfade.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||||
if (geraet.typ === 'cd') {
|
if (geraet.typ === 'cd') {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||||
@@ -233,13 +295,22 @@ export class Pipeline {
|
|||||||
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (geraet.typ === 'unknown') {
|
// 5.7.0 (F10): „unbekannt" heißt nur dann ISO, wenn NIEMAND Titel gewählt
|
||||||
|
// hat. Liegt ein Titel-Auftrag vor, hat makemkvcon die Disc gerade
|
||||||
|
// gelesen — dann ist es eine Video-Disc, egal was der Windows-Steuercode
|
||||||
|
// zum Typ sagte (bei manchen USB-Brücken antwortet er gar nicht).
|
||||||
|
if (geraet.typ === 'unknown' && auftrag.length === 0) {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
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)
|
this.melden(this.laufend.get(id)!.status)
|
||||||
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe
|
||||||
|
// ist es eine Blu-ray oder DVD — der Typ steuert nur noch Preset und
|
||||||
|
// Ordnername.
|
||||||
|
const discTyp: DiscTyp = geraet.typ === 'unknown' ? (geraet.groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd') : geraet.typ
|
||||||
|
|
||||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||||
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
||||||
// behält seinen Zeitstempel-Namen als Kennung.
|
// behält seinen Zeitstempel-Namen als Kennung.
|
||||||
@@ -260,7 +331,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: vorgangsName,
|
id: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: anzeigeTitel,
|
titel: anzeigeTitel,
|
||||||
jahr: zuordnung?.jahr ?? null,
|
jahr: zuordnung?.jahr ?? null,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
@@ -279,10 +350,20 @@ export class Pipeline {
|
|||||||
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
||||||
this.laufend.delete(id)
|
this.laufend.delete(id)
|
||||||
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
||||||
// Punkt „Aufräumen nach Abbruch").
|
// Punkt „Aufräumen nach Abbruch"). 5.7.0 (Durchsicht F8): AUSSER dem
|
||||||
|
// Rettungs-Abbild — dafür wurde die Disc stundenlang Sektor für Sektor
|
||||||
|
// gelesen; es bleibt liegen, auch wenn der Rip daraus leer ausging.
|
||||||
if (alleDateien.size === 0) {
|
if (alleDateien.size === 0) {
|
||||||
try {
|
try {
|
||||||
rmSync(rohOrdner, { recursive: true, force: true })
|
const rettungDa = existsSync(join(rohOrdner, RETTUNG_ISO))
|
||||||
|
if (rettungDa) {
|
||||||
|
for (const name of dateienIn(rohOrdner)) {
|
||||||
|
if (!RETTUNG_DATEIEN.includes(name)) rmSync(join(rohOrdner, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
text += ` Das Rettungs-Abbild bleibt unter ${rohOrdner}.`
|
||||||
|
} else {
|
||||||
|
rmSync(rohOrdner, { recursive: true, force: true })
|
||||||
|
}
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
||||||
}
|
}
|
||||||
@@ -302,7 +383,7 @@ export class Pipeline {
|
|||||||
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
||||||
let quellePfad = geraet.pfad
|
let quellePfad = geraet.pfad
|
||||||
if (rettung) {
|
if (rettung) {
|
||||||
const isoPfad = join(rohOrdner, 'rettung.iso')
|
const isoPfad = join(rohOrdner, RETTUNG_ISO)
|
||||||
this.melden({
|
this.melden({
|
||||||
id,
|
id,
|
||||||
phase: 'rettet',
|
phase: 'rettet',
|
||||||
@@ -430,7 +511,7 @@ export class Pipeline {
|
|||||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||||
|
|
||||||
if (rip.status === 'cancelled') {
|
if (rip.status === 'cancelled') {
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
// Nur dieser Titel — oder der ganze Auftrag?
|
// Nur dieser Titel — oder der ganze Auftrag?
|
||||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||||
beenden(
|
beenden(
|
||||||
@@ -452,7 +533,7 @@ export class Pipeline {
|
|||||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||||
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -527,7 +608,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: gruppenId,
|
id: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: gruppenZuordnung.titel,
|
titel: gruppenZuordnung.titel,
|
||||||
jahr: gruppenZuordnung.jahr,
|
jahr: gruppenZuordnung.jahr,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
@@ -538,7 +619,7 @@ export class Pipeline {
|
|||||||
auftrag: {
|
auftrag: {
|
||||||
vorgangId: gruppenId,
|
vorgangId: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
dateien: gruppe.dateien,
|
dateien: gruppe.dateien,
|
||||||
zuordnung: gruppenZuordnung,
|
zuordnung: gruppenZuordnung,
|
||||||
@@ -559,7 +640,7 @@ export class Pipeline {
|
|||||||
const kompression: KompressionsAuftrag = {
|
const kompression: KompressionsAuftrag = {
|
||||||
vorgangId: vorgangsName,
|
vorgangId: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
dateien: hauptDateien,
|
dateien: hauptDateien,
|
||||||
zuordnung: hauptZuordnung,
|
zuordnung: hauptZuordnung,
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ const kontext: KernKontext = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F2): Ein Fehler, den niemand fängt, darf
|
||||||
|
// nicht still verschwinden. Gemessen in Electron 44: Ein utilityProcess
|
||||||
|
// STIRBT NICHT an einer unbehandelten Promise-Ablehnung — er schweigt nur,
|
||||||
|
// und die Konsole eines Tray-Programms sieht niemand. Deshalb landet jede
|
||||||
|
// solche Ablehnung im Protokoll und im Fenster (R4). Eine echte Ausnahme
|
||||||
|
// wird protokolliert und beendet den Kern kontrolliert — der Haupt startet
|
||||||
|
// ihn neu und verdrahtet das Fenster wieder (kernstart.ts).
|
||||||
|
process.on('unhandledRejection', (grund: unknown) => {
|
||||||
|
kontext.fehler(`Unbehandelte Ablehnung im Kern: ${String(grund instanceof Error ? (grund.stack ?? grund.message) : grund).slice(0, 600)}`)
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (fehler: Error) => {
|
||||||
|
kontext.fehler(`Absturz im Kern: ${String(fehler.stack ?? fehler.message).slice(0, 600)} — der Kern beendet sich, der Haupt startet ihn neu.`)
|
||||||
|
setTimeout(() => process.exit(1), 200)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
||||||
const einstellungen = new EinstellungsDienst(kontext)
|
const einstellungen = new EinstellungsDienst(kontext)
|
||||||
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
||||||
@@ -101,10 +116,13 @@ const laufwerke = new LaufwerkDienst(
|
|||||||
kontext,
|
kontext,
|
||||||
{
|
{
|
||||||
discEingelegt: (geraet) => {
|
discEingelegt: (geraet) => {
|
||||||
void erkennung.discErmitteln(geraet).then((zuordnung) => {
|
void erkennung
|
||||||
automatik.zuordnungMerken(geraet.id, zuordnung)
|
.discErmitteln(geraet)
|
||||||
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
.then((zuordnung) => {
|
||||||
})
|
automatik.zuordnungMerken(geraet.id, zuordnung)
|
||||||
|
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
||||||
|
})
|
||||||
|
.catch((fehler: unknown) => kontext.fehler(`Nach dem Einlegen in ${geraet.id}: ${String(fehler)}`))
|
||||||
},
|
},
|
||||||
discEntnommen: (geraet) => {
|
discEntnommen: (geraet) => {
|
||||||
erkennung.discVergessen(geraet.id)
|
erkennung.discVergessen(geraet.id)
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class DiscWache {
|
|||||||
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||||
runde(): void {
|
runde(): void {
|
||||||
if (!this.laeuft) return
|
if (!this.laeuft) return
|
||||||
|
// 5.7.0 (Durchsicht F6): Eine von außen angestoßene Runde (nach dem
|
||||||
|
// Auswurf) ersetzt die geplante — vorher legte JEDER Aufruf eine zweite
|
||||||
|
// Takt-Kette an, und nach zwanzig Discs fragte Rippy die Laufwerke alle
|
||||||
|
// 150 ms statt alle 3 s (Sonde 3: neun statt fünf Runden).
|
||||||
|
if (this.zeiger !== null) {
|
||||||
|
clearTimeout(this.zeiger)
|
||||||
|
this.zeiger = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const liste = this.lesen()
|
const liste = this.lesen()
|
||||||
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||||
|
|||||||
@@ -259,3 +259,73 @@ describe('Pipeline — die Rip-Phase (5.4.0)', () => {
|
|||||||
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
|
||||||
|
describe('Pipeline — Durchsicht 5.7.0', () => {
|
||||||
|
// F2: Vorher warf mkdirSync aus starten() heraus, der Aufrufer schluckte
|
||||||
|
// die Ablehnung — kein Fehler im Fenster, keine Zeile im Protokoll
|
||||||
|
// (Sonde 10). Jetzt: eine Fehler-Meldung, keine Ausnahme.
|
||||||
|
it('F2: Ablage nicht erreichbar → Fehler-Meldung statt stiller Ausnahme', async () => {
|
||||||
|
// Ein Laufwerksbuchstabe, den es nicht gibt — wie eine NAS, die gerade
|
||||||
|
// weg ist. (Unter Linux ein Ort, an dem niemand anlegen darf.)
|
||||||
|
const weg = process.platform === 'win32' ? 'Q:\\rippy-nicht-da' : '/proc/rippy-nicht-da'
|
||||||
|
const { pipeline, meldungen, endet } = umgebung({ ablage: () => weg })
|
||||||
|
await pipeline.starten('G', AUFTRAG)
|
||||||
|
expect(meldungen).toHaveLength(1)
|
||||||
|
expect(meldungen[0].phase).toBe('fehler')
|
||||||
|
expect(meldungen[0].text).toContain('nicht erreichbar')
|
||||||
|
expect(meldungen[0].text).toContain(weg)
|
||||||
|
expect(endet).toHaveLength(0) // kein Vorgang angelegt, also keiner beendet
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2: eine Ausnahme mitten im Lauf wird zur Fehler-Meldung des Laufwerks', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen } = umgebung({
|
||||||
|
vorgangBeginnt: () => {
|
||||||
|
throw new Error('Datenbank gesperrt')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(pipeline.starten('G', AUFTRAG)).resolves.toBeUndefined()
|
||||||
|
expect(meldungen.at(-1)!.phase).toBe('fehler')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Datenbank gesperrt')
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F10: Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc ist ein
|
||||||
|
// Video-Rip, kein ISO — makemkvcon hat die Titel ja gerade geliefert.
|
||||||
|
it('F10: Typ unbekannt MIT Titel-Auftrag rippt mit MakeMKV (Typ nach Größe)', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen, eingereiht } = umgebung({ geraet: () => ({ ...GERAET, typ: 'unknown' }) })
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(meldungen.some((m) => m.text.includes('1:1-Abbild'))).toBe(false)
|
||||||
|
expect(eingereiht).toHaveLength(1)
|
||||||
|
expect(eingereiht[0].discTyp).toBe('bluray')
|
||||||
|
})
|
||||||
|
|
||||||
|
// F8: Der Roh-Ordner ohne brauchbare Datei wird geräumt — das Rettungs-
|
||||||
|
// Abbild aber nie: dafür wurde die Disc stundenlang gelesen.
|
||||||
|
it('F8: rettung.iso überlebt einen leer ausgegangenen Rip aus dem Abbild', async () => {
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }, { status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
const { pipeline, ablage, meldungen } = umgebung({
|
||||||
|
rettungsAbbild: async (_geraet, ziel) => {
|
||||||
|
writeFileSync(ziel, 'ABBILD')
|
||||||
|
writeFileSync(`${ziel}.rettung.txt`, 'Bericht')
|
||||||
|
return { status: 'success', bytes: 6, unlesbareSektoren: 1, stellen: [{ offset: 0, bytes: 2048 }] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Erst ein normaler Rip, der Lesefehler sieht — nur dann gibt es die Rettung.
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'Lesefehler', lesefehler: true }]
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(pipeline.rettungGrund('G')).not.toBe('')
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
await pipeline.rettungStarten('G')
|
||||||
|
const roh = join(ablage, 'roh')
|
||||||
|
const ordner = readdirSync(roh)
|
||||||
|
expect(ordner.length).toBeGreaterThan(0)
|
||||||
|
const inhalt = ordner.flatMap((o) => readdirSync(join(roh, o)))
|
||||||
|
expect(inhalt).toContain('rettung.iso')
|
||||||
|
expect(inhalt).toContain('rettung.iso.rettung.txt')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Rettungs-Abbild bleibt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -88,3 +88,32 @@ describe('DiscWache', () => {
|
|||||||
wache.stoppen()
|
wache.stoppen()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F6): runde() von außen — nach dem Auswurf — ersetzt die
|
||||||
|
// geplante Runde, statt eine zweite Takt-Kette anzulegen. Vorher: nach
|
||||||
|
// zwanzig Auswürfen zwanzig Ketten (Sonde 3, neun statt fünf Runden).
|
||||||
|
describe('DiscWache — eine Takt-Kette, egal wie oft runde() von außen kommt', () => {
|
||||||
|
it('drei Aufrufe von außen, danach nur EINE Kette', async () => {
|
||||||
|
let gelesen = 0
|
||||||
|
const wache = new DiscWache(
|
||||||
|
() => {
|
||||||
|
gelesen += 1
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
wache.starten()
|
||||||
|
wache.runde()
|
||||||
|
wache.runde()
|
||||||
|
const vorher = gelesen
|
||||||
|
await new Promise((f) => setTimeout(f, 130))
|
||||||
|
wache.stoppen()
|
||||||
|
const runden = gelesen - vorher
|
||||||
|
// Eine Kette im 20-ms-Takt schafft in 130 ms höchstens ~7 Runden; drei
|
||||||
|
// Ketten schafften vorher ~19.
|
||||||
|
expect(runden).toBeLessThanOrEqual(8)
|
||||||
|
expect(runden).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user