From 2d9cef22b33d75c74c493f2b086f0f6e3db42262 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 18:56:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(v5):=20Etappe=20W-7=20=E2=80=94=20Audio-CD?= =?UTF-8?q?,=20ISO-Backup,=20MakeMKV-Schluesselkette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rippy-windows/src/fenster/App.tsx | 26 +++ rippy-windows/src/fenster/kernverbindung.ts | 6 + rippy-windows/src/gemeinsam/nachrichten.ts | 6 + rippy-windows/src/haupt/index.ts | 1 + rippy-windows/src/haupt/kernstart.ts | 7 +- rippy-windows/src/kern/ablauf/pipeline.ts | 197 ++++++++++++++++++ rippy-windows/src/kern/index.ts | 57 ++++- rippy-windows/src/kern/laufwerk/disc.ts | 40 ++++ .../src/kern/metadaten/musicbrainz.ts | 123 +++++++++++ rippy-windows/src/kern/rip/audiocd.ts | 144 +++++++++++++ rippy-windows/src/kern/rip/iso.ts | 80 +++++++ .../src/kern/werkzeuge/schluessel.ts | 195 +++++++++++++++++ rippy-windows/test/audiocd.test.ts | 163 +++++++++++++++ rippy-windows/test/pipeline.test.ts | 2 + 14 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 rippy-windows/src/kern/metadaten/musicbrainz.ts create mode 100644 rippy-windows/src/kern/rip/audiocd.ts create mode 100644 rippy-windows/src/kern/rip/iso.ts create mode 100644 rippy-windows/src/kern/werkzeuge/schluessel.ts create mode 100644 rippy-windows/test/audiocd.test.ts diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index 14cee29..3c1225d 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -322,6 +322,13 @@ function Einstellungen({ verbindung }: { verbindung: Verbindung }) { setzen('tmdbApiKey', w)} /> setzen('omdbApiKey', w)} /> + setzen('makemkvAppKey', w)} + />
@@ -426,6 +433,7 @@ export default function App() { einstellungen: {}, werkzeuge: null, bibliothek: [], + schluessel: null, meldungen: [], }) @@ -535,6 +543,24 @@ export default function App() { : ['Ping unterwegs …'] } /> +
diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index aeaac0d..fb29775 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -29,6 +29,7 @@ export interface Verbindung { einstellungen: Record werkzeuge: WerkzeugAuskunft | null bibliothek: BibliothekEintrag[] + schluessel: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } | null /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ meldungen: string[] } @@ -47,6 +48,7 @@ const stand: Verbindung = { einstellungen: {}, werkzeuge: null, bibliothek: [], + schluessel: null, meldungen: [], } const horcher = new Set() @@ -122,6 +124,10 @@ function kernNachricht(nachricht: KernNachricht): void { case 'bibliothek': stand.bibliothek = nachricht.eintraege break + case 'schluessel-status': + stand.schluessel = nachricht.stand + if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) + break case 'rip-status': { stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } if (nachricht.status.phase === 'fertig') { diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 98f20c6..0032aba 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -106,6 +106,10 @@ export type KernNachricht = | { art: 'einstellungen'; werte: Record } | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } | { art: 'bibliothek'; eintraege: BibliothekEintrag[] } + | { + art: 'schluessel-status' + stand: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } + } /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */ export type FensterNachricht = @@ -169,6 +173,8 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht { return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record).presets) case 'bibliothek': return Array.isArray(wert.eintraege) + case 'schluessel-status': + return istObjekt(wert.stand) && typeof (wert.stand as Record).text === 'string' default: return false } diff --git a/rippy-windows/src/haupt/index.ts b/rippy-windows/src/haupt/index.ts index 283397f..667c1f9 100644 --- a/rippy-windows/src/haupt/index.ts +++ b/rippy-windows/src/haupt/index.ts @@ -79,6 +79,7 @@ function start(): void { } }, () => statusSenden(), + smokeModus, ) // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem diff --git a/rippy-windows/src/haupt/kernstart.ts b/rippy-windows/src/haupt/kernstart.ts index 872ab4e..53a5982 100644 --- a/rippy-windows/src/haupt/kernstart.ts +++ b/rippy-windows/src/haupt/kernstart.ts @@ -26,11 +26,16 @@ export class KernVerwaltung { private readonly datenbankPfad: string, private readonly beiNachricht: (nachricht: KernNachricht) => void, private readonly beiZustand: (zustand: KernZustand) => void, + private readonly smokeModus = false, ) {} starten(): void { const kernDatei = join(__dirname, 'kern.js') - const kern = utilityProcess.fork(kernDatei, [`--datenbank=${this.datenbankPfad}`], { + const args = [`--datenbank=${this.datenbankPfad}`] + // Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt + // die Schlüsselkette dann aus. + if (this.smokeModus) args.push('--smoke-kern') + const kern = utilityProcess.fork(kernDatei, args, { serviceName: 'Rippy-Kern', stdio: 'inherit', }) diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 5bfc18e..bcd1c18 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -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 { + 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((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 { + 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 diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 965b1f0..740a7fb 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -16,7 +16,14 @@ import { } from '../gemeinsam/nachrichten' import { Pipeline } from './ablauf/pipeline' import type { LaufwerkApi } from './laufwerk/codes' -import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc' +import { + audioSpurLesen, + audioToc, + auswerfenMitGrund, + geraeteInfo, + kennung, + listOptischeLaufwerke, +} from './laufwerk/disc' import { DiscWache } from './laufwerk/wache' import { discFingerprint, discTitel } from './metadaten/discmerkmale' import { OmdbClient } from './metadaten/omdb' @@ -196,9 +203,25 @@ function einstellungenLaden(): Record { return werte } +function geraetVon(id: string): string | null { + if (laufwerkApi === null) return null + return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null +} + const ripVerwaltung = new Pipeline({ makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), + flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''), + audioToc: (id) => { + const geraet = geraetVon(id) + if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) + return audioToc(laufwerkApi, geraet) + }, + audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => { + const geraet = geraetVon(id) + if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) + return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt) + }, geraet: (id) => { if (laufwerkApi === null) return null const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) @@ -237,6 +260,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([ 'mediaServerApiKey', 'tmdbApiKey', 'omdbApiKey', + 'makemkvAppKey', 'autoAuswurf', 'werkzeug.makemkv', 'werkzeug.handbrake', @@ -252,6 +276,29 @@ function einstellungenSenden(): void { anFenster({ art: 'einstellungen', werte }) } +// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ────────────── +let schluesselStand: Extract['stand'] | null = null + +async function schluesselLauf(): Promise { + if (process.platform !== 'win32') return + try { + const { schluesselKette } = await import('./werkzeuge/schluessel') + const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + schluesselStand = await schluesselKette( + makemkvcon, + db?.einstellungLesen('makemkvAppKey') ?? '', + new Date().toISOString(), + ) + anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand }) + } catch (fehler) { + // R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt. + console.error(`[kern] Schlüsselkette: ${String(fehler)}`) + anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` }) + } +} +// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten. +setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref() + async function werkzeugeSenden(): Promise { const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '') @@ -336,6 +383,9 @@ process.parentPort.on('message', (ereignis) => { anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` }) } anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] }) + if (schluesselStand !== null) { + anFenster({ art: 'schluessel-status', stand: schluesselStand }) + } }) } }) @@ -350,3 +400,8 @@ function bereitNachricht(): KernNachricht { } process.parentPort.postMessage(bereitNachricht()) +// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an — +// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein. +if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { + void schluesselLauf() +} diff --git a/rippy-windows/src/kern/laufwerk/disc.ts b/rippy-windows/src/kern/laufwerk/disc.ts index 56aeb18..056e3b9 100644 --- a/rippy-windows/src/kern/laufwerk/disc.ts +++ b/rippy-windows/src/kern/laufwerk/disc.ts @@ -18,9 +18,12 @@ import { ERROR_NOT_READY, ERROR_NOT_SUPPORTED, ERROR_NO_MEDIA_IN_DRIVE, + CDROM_TOC_BYTES, DRIVE_CDROM, GENERIC_READ, IOCTL_CDROM_DISK_TYPE, + IOCTL_CDROM_RAW_READ, + IOCTL_CDROM_READ_TOC, IOCTL_DISK_GET_LENGTH_INFO, IOCTL_STORAGE_CHECK_VERIFY2, IOCTL_STORAGE_EJECT_MEDIA, @@ -34,6 +37,7 @@ import { type LaufwerkApi, } from './codes' import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten' +import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd' export type { DiscTyp, GeraeteInfo, LaufwerkStatus } @@ -179,6 +183,42 @@ export async function auswerfenMitGrund( } } +// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ───────────────────────────── +/** Das Inhaltsverzeichnis einer Audio-CD — leere Liste, wenn keine drin. + * Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */ +export function audioToc(api: LaufwerkApi, geraet: string): Spur[] { + return mitHandle(api, geraet, GENERIC_READ, (h) => { + const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES) + return tocZerlegen(roh) + }) +} + +/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block — + * eine 47-MB-Spur wandert so nie komplett in den Speicher. + * ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor + * 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */ +export function audioSpurLesen( + api: LaufwerkApi, + geraet: string, + start: number, + sektoren: number, + schreiben: (daten: Buffer) => void, + fortschritt?: (anteil: number) => void, +): number { + return mitHandle(api, geraet, GENERIC_READ, (h) => { + const teile = bloecke(start, sektoren) + let gelesen = 0 + for (let i = 0; i < teile.length; i++) { + const [von, anzahl] = teile[i] + const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES) + schreiben(daten) + gelesen += daten.length + fortschritt?.((i + 1) / teile.length) + } + return gelesen + }) +} + // ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die // Ursache und behielt sie für sich — nie wieder) ──────────────────────── const ZUGRIFFS_GRUENDE: Record = { diff --git a/rippy-windows/src/kern/metadaten/musicbrainz.ts b/rippy-windows/src/kern/metadaten/musicbrainz.ts new file mode 100644 index 0000000..2819200 --- /dev/null +++ b/rippy-windows/src/kern/metadaten/musicbrainz.ts @@ -0,0 +1,123 @@ +// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3, +// Vorlage musicbrainz_cd.py — Rechnung laut +// musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte +// AC/DC-„Back in Black"-Antwort bewiesen). +// +// DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das +// Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die +// MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann +// einfach „unbekannte Disc". +import { createHash } from 'node:crypto' +import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd' + +const DIENST = 'https://musicbrainz.org/ws/2/discid/' +// MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie +// wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie). +const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' } +/** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */ +export const PLAETZE = 99 + +/** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */ +export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string { + if (spurVersaetze.length === 0) return '' + const letzteSpur = letzte ?? erste + spurVersaetze.length - 1 + const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0') + const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0') + const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)] + for (let platz = 0; platz < PLAETZE; platz++) { + teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0)) + } + const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64') + // MusicBrainz' eigenes Alphabet: + / = werden zu . _ - + return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-') +} + +/** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */ +export function kennungAusSpuren(spuren: readonly Spur[]): string { + if (spuren.length === 0) return '' + const geordnet = [...spuren].sort((a, b) => a.nr - b.nr) + const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES) + const letzte = geordnet[geordnet.length - 1] + const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES + return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr) +} + +export interface MusicBrainzAntwort { + releases?: Array<{ + title?: string + date?: string + 'artist-credit'?: Array<{ name?: string; joinphrase?: string }> + media?: Array<{ + tracks?: Array<{ + position?: number | string + title?: string + 'artist-credit'?: Array<{ name?: string; joinphrase?: string }> + }> + }> + }> +} + +/** Nachschlagen — null bei unbekannter Disc, keinem Netz oder Fremdem: + * alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */ +export async function abfragen( + kennung: string, + holen: typeof fetch = fetch, +): Promise { + if (kennung.length === 0) return null + try { + const antwort = await holen( + `${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`, + { headers: KOPF, signal: AbortSignal.timeout(15_000) }, + ) + if (antwort.status >= 300) return null + return (await antwort.json()) as MusicBrainzAntwort + } catch { + return null + } +} + +function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string { + // „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " + + // „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte. + return (eintrag ?? []) + .map((s) => (s.name ?? '') + (s.joinphrase ?? '')) + .join('') + .trim() +} + +/** Antwort → {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc — + * bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */ +export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map> { + const tags = new Map>() + const release = antwort?.releases?.[0] + if (release === undefined) return tags + const album = release.title ?? '' + const jahr = (release.date ?? '').slice(0, 4) + const albumKuenstler = kuenstler(release['artist-credit']) + for (const medium of release.media ?? []) { + for (const spur of medium.tracks ?? []) { + const nr = Number(spur.position ?? 0) + if (!Number.isInteger(nr) || nr <= 0) continue + tags.set(nr, { + TITLE: spur.title ?? '', + ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler, + ALBUM: album, + ALBUMARTIST: albumKuenstler, + DATE: jahr, + TRACKNUMBER: String(nr), + }) + } + } + return tags +} + +/** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */ +export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string { + const tags = tagsJeSpur(antwort) + if (tags.size === 0) return '' + const erste = tags.get(Math.min(...tags.keys()))! + const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || '' + const album = erste['ALBUM'] || '' + const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer + return saubern(roh) +} diff --git a/rippy-windows/src/kern/rip/audiocd.ts b/rippy-windows/src/kern/rip/audiocd.ts new file mode 100644 index 0000000..cfdfd97 --- /dev/null +++ b/rippy-windows/src/kern/rip/audiocd.ts @@ -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[] { + 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 +} diff --git a/rippy-windows/src/kern/rip/iso.ts b/rippy-windows/src/kern/rip/iso.ts new file mode 100644 index 0000000..aeb582b --- /dev/null +++ b/rippy-windows/src/kern/rip/iso.ts @@ -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 { + 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 } +} diff --git a/rippy-windows/src/kern/werkzeuge/schluessel.ts b/rippy-windows/src/kern/werkzeuge/schluessel.ts new file mode 100644 index 0000000..4c32425 --- /dev/null +++ b/rippy-windows/src/kern/werkzeuge/schluessel.ts @@ -0,0 +1,195 @@ +// Die MakeMKV-Schlüsselkette (KONZEPT § 9.1) — Vorlagen makemkv_key.py +// (Forum-Abruf) und makemkv_aufruf.py (Registry-Ablage + Prüfung, beides +// am Commander-PC gemessen): +// +// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles. +// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053). +// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf +// ist der Linux-Weg; unter Windows liest MakeMKV die Registry. +// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/ +// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als +// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN. +// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV +// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen). +import { spawn, spawnSync } from 'node:child_process' +import { textVon } from '../rip/parser' +import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser' + +export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053' +export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/ +const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV' +const KEY_NAME = 'app_Key' + +/** Den Beta-Key aus dem Forum-HTML ziehen (pur). */ +export function extractKey(html: string): string { + const treffer = KEY_MUSTER.exec(html ?? '') + return treffer === null ? '' : treffer[0] +} + +/** „valid until/through " aus dem Thread — best effort, Rohtext. + * § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt + * aber makemkvcon selbst (MSG 5051 = abgelaufen). */ +export function extractGueltigBis(html: string): string { + const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec( + html ?? '', + ) + return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1') +} + +export interface ForumKey { + key: string + gueltigBis: string +} + +/** Der aktuelle Beta-Key aus dem Forum — 8-s-Zeitgrenze je Versuch: Das + * Forum fiel bei der Messung in zwei von drei Abrufen aus (§ 9). */ +export async function betaKeyHolen(holen: typeof fetch = fetch): Promise { + for (let versuch = 0; versuch < 3; versuch++) { + try { + const antwort = await holen(FORUM_URL, { + signal: AbortSignal.timeout(8_000), + headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' }, + }) + if (antwort.status >= 300) continue + const html = await antwort.text() + const key = extractKey(html) + if (key.length > 0) return { key, gueltigBis: extractGueltigBis(html) } + } catch { + // nächster Versuch — der Fehlschlag wird am Ende als null sichtbar + } + } + return null +} + +// ── Registry-Ablage über reg.exe (kein zweiter koffi-Ort, R1) ─────────── +// In dieser Registry steckt die Arbeit des Commanders (Laufwerksprofile, +// Fensterlage). Geschrieben wird GENAU app_Key und app_UpdateEnable — +// gelöscht wird nur app_Key, und nur nach Ablehnung. +function reg(args: string[]): { ok: boolean; ausgabe: string } { + const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' }) + return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') } +} + +export function keyAblegen(key: string): boolean { + if (key.trim().length === 0) return false + return reg(['add', REGISTRY_PFAD, '/v', KEY_NAME, '/t', 'REG_SZ', '/d', key.trim(), '/f']).ok +} + +export function keyZuruecknehmen(): boolean { + return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok +} + +/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt + * bewusst abgeschaltet hat, behält seine Entscheidung. */ +export function updateKanalSicherstellen(): boolean { + const vorhanden = reg(['query', REGISTRY_PFAD, '/v', 'app_UpdateEnable']) + if (vorhanden.ok) return false + return reg(['add', REGISTRY_PFAD, '/v', 'app_UpdateEnable', '/t', 'REG_DWORD', '/d', '1', '/f']).ok +} + +export interface KeyUrteil { + ok: boolean + code: number | null + grund: string +} + +/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt: + * `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung + * kommt trotzdem (makemkv_aufruf.py, 29.08.2026). */ +export function keyPruefen(programm: string, timeoutMs = 120_000): Promise { + return new Promise((fertig) => { + if (programm.length === 0) { + fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden' }) + return + } + const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stuecke: Buffer[] = [] + kind.stdout.on('data', (s: Buffer) => stuecke.push(s)) + kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) + const zeiger = setTimeout(() => kind.kill(), timeoutMs) + kind.on('error', (fehler) => { + clearTimeout(zeiger) + fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})` }) + }) + kind.on('close', () => { + clearTimeout(zeiger) + for (const zeile of textVon(Buffer.concat(stuecke)).split(/\r?\n/)) { + if (!zeile.startsWith('MSG:')) continue + const code = Number(zeile.slice(4).split(',', 1)[0]) + if (!Number.isInteger(code)) continue + if (KEY_ABGELEHNT.includes(code) || code === 5051) { + fertig({ ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}` }) + return + } + } + fertig({ ok: true, code: null, grund: '' }) + }) + }) +} + +export interface SchluesselStand { + /** 'dauerlizenz' | 'forum' | 'keiner' */ + quelle: string + angenommen: boolean | null + gueltigBis: string + geholtAm: string + text: string +} + +/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN → + * bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */ +export async function schluesselKette( + makemkvcon: string, + dauerlizenz: string, + jetztIso: string, + holen: typeof fetch = fetch, +): Promise { + updateKanalSicherstellen() + + if (dauerlizenz.trim().length > 0) { + keyAblegen(dauerlizenz) + const urteil = await keyPruefen(makemkvcon) + return { + quelle: 'dauerlizenz', + angenommen: urteil.ok, + gueltigBis: '', + geholtAm: jetztIso, + text: urteil.ok ? 'Eigener Schlüssel hinterlegt und angenommen.' : `Eigener Schlüssel abgelehnt: ${urteil.grund}`, + } + } + + const forum = await betaKeyHolen(holen) + if (forum === null) { + // Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und + // wird geprüft, damit der Stand ehrlich ist. + const urteil = await keyPruefen(makemkvcon) + return { + quelle: 'keiner', + angenommen: urteil.ok, + gueltigBis: '', + geholtAm: jetztIso, + text: urteil.ok + ? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.' + : `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`, + } + } + + keyAblegen(forum.key) + const urteil = await keyPruefen(makemkvcon) + if (!urteil.ok) { + // Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen. + keyZuruecknehmen() + } + return { + quelle: 'forum', + angenommen: urteil.ok, + gueltigBis: forum.gueltigBis, + geholtAm: jetztIso, + text: urteil.ok + ? `Beta-Key aus dem Forum hinterlegt${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.` + : `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. Vermutlich ist MakeMKV zu alt.`, + } +} diff --git a/rippy-windows/test/audiocd.test.ts b/rippy-windows/test/audiocd.test.ts new file mode 100644 index 0000000..36141fa --- /dev/null +++ b/rippy-windows/test/audiocd.test.ts @@ -0,0 +1,163 @@ +// Tests der Audio-CD-Rechnungen und der MusicBrainz-Kennung — die +// Referenzzahlen sind die ECHTE AC/DC-„Back in Black"-Antwort vom +// 29.08.2026 (test_musicbrainz_cd.py: „abgefragt, nicht erinnert"). +import { describe, expect, it } from 'vitest' +import { + bloecke, + dateiname, + dauerText, + flacBefehl, + msfZuLba, + rawReadInfo, + sauberName, + tocZerlegen, + wavKopf, + OFFSET_EINHEIT, + SEKTOR_BYTES, + VORLAUF_FRAMES, +} from '../src/kern/rip/audiocd' +import { discId, kennungAusSpuren, tagsJeSpur, PLAETZE } from '../src/kern/metadaten/musicbrainz' +import { extractGueltigBis, extractKey } from '../src/kern/werkzeuge/schluessel' + +const ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937, 133712, 151660, 170112] +const ACDC_LEAD_OUT = 189281 +const ACDC_KENNUNG = '3KVSIWn_ewv9z0cCizmOJzDWtsQ-' + +describe('MusicBrainz-DiscID', () => { + it('DIE Referenz: stimmt mit MusicBrainz überein', () => { + expect(discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10)).toBe(ACDC_KENNUNG) + }) + it('der Vorlauf wird wieder HINZUgerechnet (LBA→MSF-Falle)', () => { + const spuren = ACDC_VERSAETZE.map((versatz, i) => ({ + nr: i + 1, + start: versatz - VORLAUF_FRAMES, + sektoren: (i + 1 < ACDC_VERSAETZE.length ? ACDC_VERSAETZE[i + 1] : ACDC_LEAD_OUT) - versatz, + audio: true, + dauer: '', + })) + expect(kennungAusSpuren(spuren)).toBe(ACDC_KENNUNG) + }) + it('MusicBrainz-Alphabet: kein + / =', () => { + const kennung = discId(ACDC_VERSAETZE, ACDC_LEAD_OUT, 1, 10) + expect(/[+/=]/.test(kennung)).toBe(false) + }) + it('immer 99 Plätze — und jede Zahl zählt', () => { + expect(PLAETZE).toBe(99) + expect(discId([150, 20000], 40000, 1, 2)).not.toBe(discId([150, 20001], 40000, 1, 2)) + expect(discId([], 1000)).toBe('') + expect(kennungAusSpuren([])).toBe('') + }) +}) + +describe('MSF/LBA und TOC', () => { + it('msfZuLba zieht die 150 Vorlauf-Frames ab', () => { + expect(msfZuLba(0, 2, 0)).toBe(0) + expect(msfZuLba(0, 2, 32)).toBe(32) + expect(msfZuLba(1, 0, 0)).toBe(75 * 60 - 150) + expect(dauerText(4500)).toBe('1:00') + expect(dauerText(75 * 61)).toBe('1:01') + }) + it('tocZerlegen liest Spuren, erkennt Daten-Tracks, braucht den Lead-Out', () => { + // CDROM_TOC: 4 Byte Kopf + Einträge à 8 Byte (…, Adr/Control, Nr, …, 0,M,S,F) + const toc = Buffer.alloc(4 + 3 * 8) + const eintrag = (stelle: number, control: number, nr: number, m: number, s: number, f: number): void => { + toc[4 + stelle * 8 + 1] = control + toc[4 + stelle * 8 + 2] = nr + toc[4 + stelle * 8 + 5] = m + toc[4 + stelle * 8 + 6] = s + toc[4 + stelle * 8 + 7] = f + } + eintrag(0, 0x00, 1, 0, 2, 0) // Audio ab LBA 0 + eintrag(1, 0x04, 2, 1, 2, 0) // DATEN-Track ab LBA 4500 + eintrag(2, 0x00, 0xaa, 2, 2, 0) // Lead-Out bei LBA 9000 + const spuren = tocZerlegen(toc) + expect(spuren).toHaveLength(2) + expect(spuren[0]).toMatchObject({ nr: 1, start: 0, sektoren: 4500, audio: true, dauer: '1:00' }) + expect(spuren[1]).toMatchObject({ nr: 2, audio: false }) + }) + it('ohne Endmarke fehlt der letzten Spur die Länge — sie fällt weg', () => { + const toc = Buffer.alloc(4 + 8) + toc[4 + 2] = 1 + toc[4 + 6] = 2 + expect(tocZerlegen(toc)).toEqual([]) + expect(tocZerlegen(Buffer.alloc(4))).toEqual([]) + }) +}) + +describe('Lesen und Kodieren', () => { + it('bloecke bleibt unter der 64-KB-Treibergrenze', () => { + const teile = bloecke(1000, 60) + expect(teile[0]).toEqual([1000, 27]) + expect(teile[1]).toEqual([1027, 27]) + expect(teile[2]).toEqual([1000 + 54, 6]) + expect(27 * SEKTOR_BYTES).toBeLessThan(64 * 1024) + }) + it('rawReadInfo: DiskOffset in 2048ern, obwohl der Sektor 2352 hat', () => { + const info = rawReadInfo(100, 27) + expect(info.readBigInt64LE(0)).toBe(100n * BigInt(OFFSET_EINHEIT)) + expect(info.readUInt32LE(8)).toBe(27) + expect(info.readUInt32LE(12)).toBe(2) // CDDA + }) + it('wavKopf: 44 Byte, RIFF-Größenfelder stimmen', () => { + const kopf = wavKopf(1000) + expect(kopf.length).toBe(44) + expect(kopf.toString('ascii', 0, 4)).toBe('RIFF') + expect(kopf.readUInt32LE(4)).toBe(1036) + expect(kopf.readUInt32LE(40)).toBe(1000) + expect(kopf.readUInt32LE(24)).toBe(44100) + }) + it('sauberName: AC/DC macht keinen Unterordner auf', () => { + expect(sauberName('AC/DC - Back in Black')).toBe('ACDC - Back in Black') + expect(sauberName('Ende. ')).toBe('Ende') + }) + it('dateiname mit und ohne Titel', () => { + expect(dateiname(1, 'Hells Bells')).toBe('01 - Hells Bells') + expect(dateiname(2)).toBe('Track 02') + }) + it('flacBefehl trägt Tags einzeln', () => { + const befehl = flacBefehl('a.wav', 'b.flac', { TITLE: 'X', LEER: '' }) + expect(befehl).toContain('--tag') + expect(befehl[befehl.indexOf('--tag') + 1]).toBe('TITLE=X') + expect(befehl.join(' ')).not.toContain('LEER=') + expect(befehl.at(-1)).toBe('a.wav') + }) +}) + +describe('tagsJeSpur', () => { + it('setzt Künstler mit Bindewörtern zusammen, nimmt nur das Jahr', () => { + const tags = tagsJeSpur({ + releases: [ + { + title: 'Back in Black', + date: '1980-07-25', + 'artist-credit': [{ name: 'AC/DC' }], + media: [ + { + tracks: [ + { position: 1, title: 'Hells Bells', 'artist-credit': [{ name: 'Simon', joinphrase: ' & ' }, { name: 'Garfunkel' }] }, + { position: '2', title: 'Shoot to Thrill' }, + ], + }, + ], + }, + ], + }) + expect(tags.get(1)?.['ARTIST']).toBe('Simon & Garfunkel') + expect(tags.get(2)?.['ARTIST']).toBe('AC/DC') + expect(tags.get(1)?.['DATE']).toBe('1980') + expect(tags.get(2)?.['TRACKNUMBER']).toBe('2') + expect(tagsJeSpur(null).size).toBe(0) + }) +}) + +describe('Schlüsselkette (§ 9.1)', () => { + it('extractKey findet den Beta-Key im Forum-HTML', () => { + const html = 'T-' + 'x'.repeat(60) + '' + expect(extractKey(html)).toBe('T-' + 'x'.repeat(60)) + expect(extractKey('kein Key hier T-kurz')).toBe('') + }) + it('extractGueltigBis liest das Ablaufdatum (best effort)', () => { + expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026') + expect(extractGueltigBis('nichts')).toBe('') + }) +}) diff --git a/rippy-windows/test/pipeline.test.ts b/rippy-windows/test/pipeline.test.ts index c5c4731..344497b 100644 --- a/rippy-windows/test/pipeline.test.ts +++ b/rippy-windows/test/pipeline.test.ts @@ -39,6 +39,7 @@ function umgebung(überschreiben: Partial = {}): { const pipeline = new Pipeline({ makemkvcon: () => 'C:\\mk\\makemkvcon64.exe', handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', + flac: () => '', geraet: () => ({ id: 'G', name: 'Laufwerk G:', @@ -141,6 +142,7 @@ describe('Pipeline', () => { const pipeline = new Pipeline({ makemkvcon: () => 'x', handbrake: () => 'y', + flac: () => '', geraet: () => ({ id: 'G', name: 'Laufwerk G:',