feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
Ampel / ampel (push) Successful in 1m20s
Ampel / ampel (push) Successful in 1m20s
rip/audiocd.ts: die Rechnungen PUR (MSF→LBA mit den 150 Vorlauf-Frames, TOC-Zerlegung mit Lead-Out-Pflicht und Daten-Track-Bit, 27-Sektoren- Bloecke unter der 64-KB-Treibergrenze, RAW_READ_INFO mit DiskOffset in 2048ern OBWOHL der Sektor 2352 hat, WAV-Kopf, AC/DC-sichere Namen). metadaten/musicbrainz.ts: DiscID nach musicbrainz.org-Rechnung — gegen die ECHTE AC/DC-Kennung getestet (3KVSIWn_…), Vorlauf wird beim Melden wieder HINZUgerechnet; Tags je Spur mit Bindewort-Kuenstlern. laufwerk/disc.ts: audioToc + audioSpurLesen ueber die LaufwerkApi. rip/iso.ts (§ 6.4): 1:1-Abbild fuer unbekannte Discs — async (ein 33-GB-Abbild darf den Kern nicht blockieren), sektor-ausgerichtet, unvollstaendig heisst FEHLER mit Byte-Zahlen, nie Schoenfaerberei. werkzeuge/schluessel.ts (§ 9.1): Dauerlizenz schlaegt Forum-Beta-Key (t=1053, T-Muster aus makemkv_key.py), Ablage in HKCU ueber reg.exe (kein zweiter koffi-Ort), GEFRAGT statt gerechnet (makemkvcon info disc:9999, MSG 5020/5021/5051), abgelehnter Key wird ZURUECKGENOMMEN (schlimmer als keiner: 0 Titel, kein Laufwerk), app_UpdateEnable nur- wenn-fehlt (der 4K-Schluesselkanal). Kern prueft beim Start und taeglich; Dashboard-Kachel zeigt Stand + letzten Pruefzeitpunkt; Dauerlizenz-Feld in den Einstellungen. Pipeline verzweigt nach Disc-Typ: cd → Sektoren→WAV→FLAC mit MusicBrainz-Tags (Ordner 'Kuenstler - Album', ohne Treffer ehrlich 'Track NN'), unknown → ISO. Bibliothek-Eintraege fuer beide. 181 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c78ff22ab7
commit
2d9cef22b3
@@ -18,7 +18,13 @@ import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, 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 { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
abbruch: AbortController
|
||||
@@ -30,6 +36,18 @@ export interface PipelineUmgebung {
|
||||
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). */
|
||||
@@ -106,6 +124,21 @@ export class Pipeline {
|
||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
|
||||
// § 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)
|
||||
|
||||
const start: RipStatus = {
|
||||
@@ -313,6 +346,170 @@ export class Pipeline {
|
||||
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) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Anzeige-Komfort — siehe oben.
|
||||
}
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
|
||||
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(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') }))
|
||||
})
|
||||
}
|
||||
|
||||
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
|
||||
|
||||
Reference in New Issue
Block a user