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
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user