// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. // EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über // AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und // hängen damit an der Prozess-Leine (R6). // // Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den // RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions- // Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts → // ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien // liegen — die nächste Disc kann rein, während die vorige noch rechnet // (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz // (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher // Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und // nach einem Abbruch nachsehen. import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs' // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. import { join } from 'node:path' import type { BibliothekEintrag, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, RipStatus, TitelRolle, } from '../../gemeinsam/nachrichten' import type { Einstellungen } from '../komprimieren/presets' import type { Zuordnung } from '../metadaten/zuordnung' import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' import { isoSichern } from '../rip/iso' import { rippen } from '../rip/makemkv' import { werkzeugSpawn } from '../werkzeuge/aufruf' import { mkdtempSync, createWriteStream } from 'node:fs' import { tmpdir } from 'node:os' import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag' interface LaufenderVorgang { /** Beendet den GANZEN Auftrag. */ abbruch: AbortController /** Beendet nur den gerade laufenden Titel (überspringen). */ titelAbbruch?: AbortController /** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */ uebersprungen?: boolean status: RipStatus } /** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */ export interface VorgangBeginn { id: string laufwerk: string typ: string titel: string jahr: number | null rohOrdner: string begonnen: string } export interface PipelineUmgebung { /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ makemkvcon: () => string /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ handbrake: () => string /** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */ flac: () => string /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ audioToc?: (id: string) => Spur[] /** Rohe Audio-Sektoren lesen (W-7). */ audioSpurLesen?: ( id: string, start: number, sektoren: number, schreiben: (daten: Buffer) => void, fortschritt?: (anteil: number) => void, ) => number /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ geraet: (id: string) => GeraeteInfo | null /** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ ablage: () => string einstellungen: () => Einstellungen melden: (status: RipStatus) => void /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ zuordnung?: (id: string) => Zuordnung | null /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ fingerprint?: (id: string) => string | null /** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */ discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) — für * Audio-CD und ISO, die keine Kompression durchlaufen. */ bibliothekEintragen?: (eintrag: BibliothekEintrag) => void /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ jetzt?: () => Date /** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht * die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */ vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number /** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */ vorgangBeginnt?: (vorgang: VorgangBeginn) => void /** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */ vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void /** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) — die nächste * Disc soll rein, während die Kompression läuft. */ nachRipAuswerfen?: (id: string) => void } export class Pipeline { private laufend = new Map() constructor(private readonly umgebung: PipelineUmgebung) {} laeuft(id: string): boolean { return this.laufend.has(id) } private melden(status: RipStatus): void { const eintrag = this.laufend.get(status.id) if (eintrag !== undefined) eintrag.status = status this.umgebung.melden(status) } private fehler(id: string, text: string): RipStatus { return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } } /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, * während Folge 2 heil daneben lag). */ ueberspringen(id: string): void { const vorgang = this.laufend.get(id) if (vorgang === undefined || vorgang.titelAbbruch === undefined) return vorgang.uebersprungen = true vorgang.titelAbbruch.abort() } /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ async starten( id: string, auftrag: readonly RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}, ): Promise { if (this.laufend.has(id)) { this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) return } const programm = this.umgebung.makemkvcon() if (programm.length === 0) { this.umgebung.melden( this.fehler( id, 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)', ), ) return } const geraet = this.umgebung.geraet(id) if (geraet === null) { this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`)) return } // Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt // einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der // Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf // nie zu „es geht nicht" werden. if (geraet.status === 'empty') { this.umgebung.melden( this.fehler( id, `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`, ), ) return } const abbruch = new AbortController() const startZeit = this.umgebung.jetzt?.() ?? new Date() const vorgangsName = vorgangsNameFuer(geraet, startZeit) // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). if (geraet.typ === 'cd') { this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) this.melden(this.laufend.get(id)!.status) await this.audioCdRippen(id, geraet, startZeit, abbruch) return } if (geraet.typ === 'unknown') { this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) this.melden(this.laufend.get(id)!.status) await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) return } const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) mkdirSync(rohOrdner, { recursive: true }) const zuordnung = this.umgebung.zuordnung?.(id) ?? null const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName this.umgebung.vorgangBeginnt?.({ id: vorgangsName, laufwerk: id, typ: geraet.typ, titel: anzeigeTitel, jahr: zuordnung?.jahr ?? null, rohOrdner, begonnen: startZeit.toISOString(), }) // TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch // 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau: // * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt // Trailer, Werbung und Alterskennzeichen. // * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht // mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der // Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren. const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all'] // Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet — // vorher sprang er je Titel zurück auf null. const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1] // Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je // Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten. const auftragJeDatei = new Map() const alleDateien = new Set() let lesefehlerGesamt = false const gescheitert: string[] = [] const uebersprungen: string[] = [] const aufgeraeumt: string[] = [] this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName }, }) const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => { this.laufend.delete(id) // Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0, // Punkt „Aufräumen nach Abbruch"). if (alleDateien.size === 0) { try { rmSync(rohOrdner, { recursive: true, force: true }) } catch (fehler) { text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})` } } this.umgebung.vorgangEndet?.(vorgangsName, phase, text) this.melden({ id, phase, prozent: -1, text, dateien: [...alleDateien], lesefehler: lesefehlerGesamt, vorgangId: vorgangsName, }) } for (let i = 0; i < laeufe.length; i++) { const welcher = laeufe[i] // Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf, // „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch. const titelAbbruch = new AbortController() const weiterreichen = (): void => titelAbbruch.abort() abbruch.signal.addEventListener('abort', weiterreichen, { once: true }) const vorgang = this.laufend.get(id) if (vorgang !== undefined) { vorgang.titelAbbruch = titelAbbruch vorgang.uebersprungen = false } const beschriftung = welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})` this.melden({ id, phase: 'rippt', prozent: gewichteterFortschritt(gewichte, i, -1), text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`, dateien: [...alleDateien], lesefehler: lesefehlerGesamt, titelNr: i + 1, titelGesamt: laeufe.length, haengt: null, vorgangId: vorgangsName, }) // Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch // oder Fehler ist eine halbe Datei und wird entfernt (5.4.0). const vorher = dateienIn(rohOrdner) const rip = await rippen({ programm, geraetepfad: geraet.pfad, zielOrdner: rohOrdner, titel: welcher, abbruch: titelAbbruch.signal, aufFortschritt: (prozent) => { const alt = this.laufend.get(id)?.status if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) }) }, aufMeldung: (_code, text) => { const alt = this.laufend.get(id)?.status if (alt !== undefined) this.melden({ ...alt, text }) }, aufHaengt: (klemmt) => { const alt = this.laufend.get(id)?.status if (alt !== undefined) this.melden({ ...alt, haengt: klemmt }) }, }) abbruch.signal.removeEventListener('abort', weiterreichen) if (rip.status === 'cancelled') { aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) // Nur dieser Titel — oder der ganze Auftrag? if (this.laufend.get(id)?.uebersprungen !== true) { beenden( 'abgebrochen', 'Abgebrochen.' + (aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') + (alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''), ) return } uebersprungen.push(beschriftung) continue } lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler if (rip.status === 'error') { // EIN kaputter Titel beendet den Auftrag nicht — die anderen sind // vielleicht heil. Der Grund wird gesammelt und am Ende genannt. // Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig. aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) gescheitert.push(`${beschriftung}: ${rip.fehler}`) continue } // rippen() liefert den ganzen Zielordner zurück, also auch die // Dateien der vorigen Titel — neu ist, was noch nicht bekannt war. const neu = rip.dateien.filter((d) => !alleDateien.has(d)) for (const d of rip.dateien) alleDateien.add(d) for (const d of neu) auftragJeDatei.set(d, auftrag[i]) if (neu.length === 0) { // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben // (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert. gescheitert.push(`${beschriftung}: keine Datei entstanden`) } } const nachLauf = this.laufend.get(id) if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined if (alleDateien.size === 0) { beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.') return } const hinweise: string[] = [] if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`) if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`) if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`) // ── Übergabe an die Warteschlange (5.4.0) ────────────────────────── const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => { const a = auftragJeDatei.get(pfad) const rolle: TitelRolle = a?.rolle ?? 'hauptfilm' return { pfad, titelNr: a?.nr ?? -1, rolle, dauerS: a?.dauerS ?? 0, groesseBytes: groesseVon(pfad), audioSprachen: a?.audioSprachen ?? [], untertitelSprachen: a?.untertitelSprachen ?? [], } }) const angaben = this.umgebung.discAngaben?.(id) ?? null const kompression: KompressionsAuftrag = { vorgangId: vorgangsName, laufwerk: id, discTyp: geraet.typ, rohOrdner, dateien, zuordnung, staffel: optionen.staffel ?? angaben?.staffel ?? null, ersteFolge: optionen.ersteFolge ?? null, discNr: angaben?.discNr ?? null, fingerprint: this.umgebung.fingerprint?.(id) ?? null, lesefehler: lesefehlerGesamt, hinweise, begonnen: startZeit.toISOString(), preset: '', zielOrdner: '', } const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0 const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0) // Das Laufwerk ist FREI — die Kompression läuft ohne es weiter. this.laufend.delete(id) this.umgebung.nachRipAuswerfen?.(id) this.melden({ id, phase: 'fertig', prozent: 100, text: `Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` + (platz > 0 ? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.` : 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') + (lesefehlerGesamt ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' : '') + (hinweise.length > 0 ? ` ⚠ ${hinweise.join(' ')}` : ''), dateien: dateien.map((d) => d.pfad), lesefehler: lesefehlerGesamt, vorgangId: vorgangsName, }) } abbrechen(id: string): void { this.laufend.get(id)?.abbruch.abort() } // ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ── private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise { const fertigMachen = (status: RipStatus): void => { this.laufend.delete(id) this.melden(status) } const flac = this.umgebung.flac() if (flac.length === 0) { fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).')) return } if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) { fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.')) return } let spuren: Spur[] try { spuren = this.umgebung.audioToc(id).filter((s) => s.audio) } catch (fehler) { fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`)) return } if (spuren.length === 0) { fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?')) return } // MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne // Treffer heißen die Dateien „Track 01" — das ist keine Ausrede, // sondern die Wahrheit über eine Disc, die niemand kennt. const kennung = kennungAusSpuren(spuren) const antwort = await abfragen(kennung) const tags = tagsJeSpur(antwort) const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit) const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName) mkdirSync(ziel, { recursive: true }) const dateien: string[] = [] for (let i = 0; i < spuren.length; i++) { if (abbruch.signal.aborted) { fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false }) return } const spur = spuren[i] const spurTags = tags.get(spur.nr) ?? {} const name = dateiname(spur.nr, spurTags['TITLE'] ?? '') const zielDatei = join(ziel, `${name}.flac`) const stand = this.laufend.get(id)?.status if (stand !== undefined) { this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) }) } // Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang // hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht. const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-')) try { const wav = join(arbeit, 'spur.wav') const strom = createWriteStream(wav) strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { const s = this.laufend.get(id)?.status if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) }) await new Promise((fertig, kaputt) => { strom.end(() => fertig()) strom.on('error', kaputt) }) const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags)) if (flacErgebnis.code !== 0) { fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`)) return } } catch (fehler) { fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`)) return } finally { rmSync(arbeit, { recursive: true, force: true }) } dateien.push(zielDatei) } const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { let groesse = 0 for (const datei of dateien) groesse += groesseVon(datei) this.umgebung.bibliothekEintragen({ fingerprint, titel: ordnerName, jahr: null, typ: 'cd', groesseBytes: groesse, ablageort: ziel, datum: startZeit.toISOString(), dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), posterPfad: '', }) } fertigMachen({ id, phase: 'fertig', prozent: 100, text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''), dateien, lesefehler: false, }) } // ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ───────────────── private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise { const ziel = join(this.umgebung.ablage(), 'ISO') mkdirSync(ziel, { recursive: true }) const zielDatei = join(ziel, `${vorgangsName}.iso`) const ergebnis = await isoSichern( geraet.pfad, zielDatei, geraet.groesseBytes, (prozent) => { const stand = this.laufend.get(id)?.status if (stand !== undefined) this.melden({ ...stand, prozent }) }, abbruch.signal, ) this.laufend.delete(id) if (ergebnis.status === 'success') { const fingerprint = this.umgebung.fingerprint?.(id) ?? null if (fingerprint !== null) { this.umgebung.bibliothekEintragen?.({ fingerprint, titel: vorgangsName, jahr: null, typ: 'iso', groesseBytes: ergebnis.bytes, ablageort: zielDatei, datum: startZeit.toISOString(), dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), posterPfad: '', }) } this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false }) } else if (ergebnis.status === 'cancelled') { this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) } else { this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false }) } } } export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` } function groesseVon(pfad: string): number { try { return statSync(pfad).size } catch { // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an // der Liste selbst. return 0 } } function dateienIn(ordner: string): Set { try { return new Set(readdirSync(ordner)) } catch { // Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und // im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist. return new Set() } } /** Entfernt, was seit `vorher` im Ordner dazukam — die halbe Datei eines * abgebrochenen oder gescheiterten Titels. Liefert die Namen. */ export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet): string[] { const entfernt: string[] = [] for (const name of dateienIn(ordner)) { if (vorher.has(name)) continue try { unlinkSync(join(ordner, name)) entfernt.push(name) } catch (fehler) { // Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da. entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`) } } return entfernt } function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { return new Promise((fertig) => { const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) const stuecke: Buffer[] = [] kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) }) }