Files
rippy/rippy-windows/src/kern/ablauf/pipeline.ts
T
HitonabiandClaude Fable 5.1 6dd1b0bc7a 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>
2026-09-01 22:22:04 +02:00

615 lines
25 KiB
TypeScript

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