feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
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:
Hitonabi
2026-08-30 18:56:07 +02:00
co-authored by Claude Fable 5
parent c78ff22ab7
commit 2d9cef22b3
14 changed files with 1045 additions and 2 deletions
+144
View File
@@ -0,0 +1,144 @@
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
// liegt roh in 2352-Byte-Sektoren.
//
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
export const SEKTOR_BYTES = 2352
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
export const OFFSET_EINHEIT = 2048
export const FRAMES_JE_SEKUNDE = 75
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
export const VORLAUF_FRAMES = 150
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
export const BLOCK_SEKTOREN = 27
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
export const CDDA = 2
export interface Spur {
nr: number
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
start: number
sektoren: number
audio: boolean
dauer: string
}
/** MSF-Adresse → logische Sektornummer (ECMA-130). Wer die 150 vergisst,
* liest jeden Track zwei Sekunden zu früh. */
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
}
export function dauerText(sektoren: number): string {
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
}
/** Die Bytes von IOCTL_CDROM_READ_TOC → Spurliste. CDROM_TOC (ntddcdrm.h):
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke —
* ohne ihn fehlt der letzten Spur die Länge). */
export function tocZerlegen(rohdaten: Buffer): Spur[] {
if (rohdaten.length < 12) return []
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
const adrControl = rohdaten[stelle + 1]
const nummer = rohdaten[stelle + 2]
const m = rohdaten[stelle + 5]
const s = rohdaten[stelle + 6]
const f = rohdaten[stelle + 7]
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
}
const spuren: Spur[] = []
for (let i = 0; i < eintraege.length; i++) {
const eintrag = eintraege[i]
if (eintrag.nr === 0xaa) continue
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
const laenge = eintraege[i + 1].lba - eintrag.lba
if (laenge <= 0) continue
spuren.push({
nr: eintrag.nr,
start: eintrag.lba,
sektoren: laenge,
audio: eintrag.audio,
dauer: dauerText(laenge),
})
}
return spuren
}
/** Einen Track in Lese-Blöcke zerlegen — am Stück scheitert an der
* 64-KB-Grenze vieler Treiber. */
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
if (sektoren <= 0 || jeBlock <= 0) return []
const teile: Array<[number, number]> = []
let gelesen = 0
while (gelesen < sektoren) {
const anzahl = Math.min(jeBlock, sektoren - gelesen)
teile.push([start + gelesen, anzahl])
gelesen += anzahl
}
return teile
}
/** Der 44-Byte-WAV-Kopf — CD-Audio IST schon PCM im WAV-Format, es fehlt
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
const byteRate = (rate * kanaele * bits) / 8
const block = (kanaele * bits) / 8
const kopf = Buffer.alloc(44)
kopf.write('RIFF', 0, 'ascii')
kopf.writeUInt32LE(36 + datenBytes, 4)
kopf.write('WAVE', 8, 'ascii')
kopf.write('fmt ', 12, 'ascii')
kopf.writeUInt32LE(16, 16)
kopf.writeUInt16LE(1, 20)
kopf.writeUInt16LE(kanaele, 22)
kopf.writeUInt32LE(rate, 24)
kopf.writeUInt32LE(byteRate, 28)
kopf.writeUInt16LE(block, 32)
kopf.writeUInt16LE(bits, 34)
kopf.write('data', 36, 'ascii')
kopf.writeUInt32LE(datenBytes, 40)
return kopf
}
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
const eingabe = Buffer.alloc(16)
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
eingabe.writeUInt32LE(anzahl, 8)
eingabe.writeUInt32LE(CDDA, 12)
return eingabe
}
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS —
* „AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
export function sauberName(text: string): string {
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
}
export function dateiname(nr: number, titel = ''): string {
const sauber = sauberName(titel)
const nummer = String(nr).padStart(2, '0')
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
}
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
* den Fortschritt selbst), Tags einzeln. */
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
const befehl = ['-8', '--totally-silent', '-o', ziel]
for (const [name, wert] of Object.entries(tags)) {
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
}
befehl.push(wav)
return befehl
}
+80
View File
@@ -0,0 +1,80 @@
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — die einzige echte Lücke
// gegenüber ARM: Wird eine Disc weder als Film noch als Musik erkannt,
// wird sie 1:1 gesichert statt abgelehnt. Sequenziell aus \\.\G: lesen;
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
// Bytes). Kein Fremdwerkzeug nötig.
//
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
// sequenziell ab 0.
import { open } from 'node:fs/promises'
const BLOCK_BYTES = 1024 * 1024
export type IsoErgebnis =
| { status: 'success'; bytes: number }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; bytes: number }
export async function isoSichern(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number) => void,
abbruch?: AbortSignal,
): Promise<IsoErgebnis> {
if (erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
}
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
}
const puffer = Buffer.alloc(BLOCK_BYTES)
let gelesen = 0
let letzterProzent = -1
try {
while (gelesen < erwarteteBytes) {
if (abbruch?.aborted === true) return { status: 'cancelled' }
const rest = erwarteteBytes - gelesen
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
if (bytesRead <= 0) break
await ziel.write(puffer, 0, bytesRead)
gelesen += bytesRead
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
if (prozent !== letzterProzent) {
letzterProzent = prozent
aufFortschritt?.(prozent)
}
}
} catch (fehler) {
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
} finally {
await quelle.close()
await ziel.close()
}
if (gelesen !== erwarteteBytes) {
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
return {
status: 'error',
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
bytes: gelesen,
}
}
return { status: 'success', bytes: gelesen }
}