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>
615 lines
25 KiB
TypeScript
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') }))
|
|
})
|
|
}
|