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
+26
View File
@@ -322,6 +322,13 @@ function Einstellungen({ verbindung }: { verbindung: Verbindung }) {
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} /> <Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} /> <Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
</div> </div>
<Feld
name="MakeMKV-Schlüssel (Dauerlizenz)"
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
geheim
wert={werte['makemkvAppKey'] ?? ''}
aufAendern={(w) => setzen('makemkvAppKey', w)}
/>
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
@@ -426,6 +433,7 @@ export default function App() {
einstellungen: {}, einstellungen: {},
werkzeuge: null, werkzeuge: null,
bibliothek: [], bibliothek: [],
schluessel: null,
meldungen: [], meldungen: [],
}) })
@@ -535,6 +543,24 @@ export default function App() {
: ['Ping unterwegs …'] : ['Ping unterwegs …']
} }
/> />
<Kachel
titel="MakeMKV-Schlüssel (§ 9.1)"
zustand={
verbindung.schluessel === null
? 'wartet'
: verbindung.schluessel.angenommen === true
? 'ok'
: 'fehler'
}
zeilen={
verbindung.schluessel === null
? ['wird geprüft …']
: [
verbindung.schluessel.text,
`zuletzt geprüft: ${verbindung.schluessel.geholtAm.slice(0, 16).replace('T', ' ')}`,
]
}
/>
</div> </div>
</section> </section>
@@ -29,6 +29,7 @@ export interface Verbindung {
einstellungen: Record<string, string> einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[] bibliothek: BibliothekEintrag[]
schluessel: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } | null
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[] meldungen: string[]
} }
@@ -47,6 +48,7 @@ const stand: Verbindung = {
einstellungen: {}, einstellungen: {},
werkzeuge: null, werkzeuge: null,
bibliothek: [], bibliothek: [],
schluessel: null,
meldungen: [], meldungen: [],
} }
const horcher = new Set<Horcher>() const horcher = new Set<Horcher>()
@@ -122,6 +124,10 @@ function kernNachricht(nachricht: KernNachricht): void {
case 'bibliothek': case 'bibliothek':
stand.bibliothek = nachricht.eintraege stand.bibliothek = nachricht.eintraege
break break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'rip-status': { case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
if (nachricht.status.phase === 'fertig') { if (nachricht.status.phase === 'fertig') {
@@ -106,6 +106,10 @@ export type KernNachricht =
| { art: 'einstellungen'; werte: Record<string, string> } | { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] } | { 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. */ /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = 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<string, unknown>).presets) return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek': case 'bibliothek':
return Array.isArray(wert.eintraege) return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
default: default:
return false return false
} }
+1
View File
@@ -79,6 +79,7 @@ function start(): void {
} }
}, },
() => statusSenden(), () => statusSenden(),
smokeModus,
) )
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem // § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
+6 -1
View File
@@ -26,11 +26,16 @@ export class KernVerwaltung {
private readonly datenbankPfad: string, private readonly datenbankPfad: string,
private readonly beiNachricht: (nachricht: KernNachricht) => void, private readonly beiNachricht: (nachricht: KernNachricht) => void,
private readonly beiZustand: (zustand: KernZustand) => void, private readonly beiZustand: (zustand: KernZustand) => void,
private readonly smokeModus = false,
) {} ) {}
starten(): void { starten(): void {
const kernDatei = join(__dirname, 'kern.js') 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', serviceName: 'Rippy-Kern',
stdio: 'inherit', stdio: 'inherit',
}) })
+197
View File
@@ -18,7 +18,13 @@ import { zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake' import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung' 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 { rippen } from '../rip/makemkv'
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
interface LaufenderVorgang { interface LaufenderVorgang {
abbruch: AbortController abbruch: AbortController
@@ -30,6 +36,18 @@ export interface PipelineUmgebung {
makemkvcon: () => string makemkvcon: () => string
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
handbrake: () => string 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). */ /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ /** 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 startZeit = this.umgebung.jetzt?.() ?? new Date()
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
const vorgangsName = `${geraet.typ}-${zeit}` 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 rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
const start: RipStatus = { const start: RipStatus = {
@@ -313,6 +346,170 @@ export class Pipeline {
abbrechen(id: string): void { abbrechen(id: string): void {
this.laufend.get(id)?.abbruch.abort() 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 // Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
+56 -1
View File
@@ -16,7 +16,14 @@ import {
} from '../gemeinsam/nachrichten' } from '../gemeinsam/nachrichten'
import { Pipeline } from './ablauf/pipeline' import { Pipeline } from './ablauf/pipeline'
import type { LaufwerkApi } from './laufwerk/codes' 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 { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel } from './metadaten/discmerkmale' import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb' import { OmdbClient } from './metadaten/omdb'
@@ -196,9 +203,25 @@ function einstellungenLaden(): Record<string, string> {
return werte 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({ const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), 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) => { geraet: (id) => {
if (laufwerkApi === null) return null if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
@@ -237,6 +260,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
'mediaServerApiKey', 'mediaServerApiKey',
'tmdbApiKey', 'tmdbApiKey',
'omdbApiKey', 'omdbApiKey',
'makemkvAppKey',
'autoAuswurf', 'autoAuswurf',
'werkzeug.makemkv', 'werkzeug.makemkv',
'werkzeug.handbrake', 'werkzeug.handbrake',
@@ -252,6 +276,29 @@ function einstellungenSenden(): void {
anFenster({ art: 'einstellungen', werte }) anFenster({ art: 'einstellungen', werte })
} }
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
let schluesselStand: Extract<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
async function schluesselLauf(): Promise<void> {
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<void> { async function werkzeugeSenden(): Promise<void> {
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '') 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: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
} }
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] }) 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()) 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()
}
+40
View File
@@ -18,9 +18,12 @@ import {
ERROR_NOT_READY, ERROR_NOT_READY,
ERROR_NOT_SUPPORTED, ERROR_NOT_SUPPORTED,
ERROR_NO_MEDIA_IN_DRIVE, ERROR_NO_MEDIA_IN_DRIVE,
CDROM_TOC_BYTES,
DRIVE_CDROM, DRIVE_CDROM,
GENERIC_READ, GENERIC_READ,
IOCTL_CDROM_DISK_TYPE, IOCTL_CDROM_DISK_TYPE,
IOCTL_CDROM_RAW_READ,
IOCTL_CDROM_READ_TOC,
IOCTL_DISK_GET_LENGTH_INFO, IOCTL_DISK_GET_LENGTH_INFO,
IOCTL_STORAGE_CHECK_VERIFY2, IOCTL_STORAGE_CHECK_VERIFY2,
IOCTL_STORAGE_EJECT_MEDIA, IOCTL_STORAGE_EJECT_MEDIA,
@@ -34,6 +37,7 @@ import {
type LaufwerkApi, type LaufwerkApi,
} from './codes' } from './codes'
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten' 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 } 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 // ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
// Ursache und behielt sie für sich — nie wieder) ──────────────────────── // Ursache und behielt sie für sich — nie wieder) ────────────────────────
const ZUGRIFFS_GRUENDE: Record<number, string> = { const ZUGRIFFS_GRUENDE: Record<number, string> = {
@@ -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<MusicBrainzAntwort | null> {
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<number, Record<string, string>> {
const tags = new Map<number, Record<string, string>>()
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)
}
+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 }
}
@@ -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 <Datum>" 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<ForumKey | null> {
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<KeyUrteil> {
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<SchluesselStand> {
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.`,
}
}
+163
View File
@@ -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 = '<code>T-' + 'x'.repeat(60) + '</code>'
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('')
})
})
+2
View File
@@ -39,6 +39,7 @@ function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
const pipeline = new Pipeline({ const pipeline = new Pipeline({
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe', makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
flac: () => '',
geraet: () => ({ geraet: () => ({
id: 'G', id: 'G',
name: 'Laufwerk G:', name: 'Laufwerk G:',
@@ -141,6 +142,7 @@ describe('Pipeline', () => {
const pipeline = new Pipeline({ const pipeline = new Pipeline({
makemkvcon: () => 'x', makemkvcon: () => 'x',
handbrake: () => 'y', handbrake: () => 'y',
flac: () => '',
geraet: () => ({ geraet: () => ({
id: 'G', id: 'G',
name: 'Laufwerk G:', name: 'Laufwerk G:',