feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
Ampel / ampel (push) Successful in 1m20s
Ampel / ampel (push) Successful in 1m20s
rip/audiocd.ts: die Rechnungen PUR (MSF→LBA mit den 150 Vorlauf-Frames, TOC-Zerlegung mit Lead-Out-Pflicht und Daten-Track-Bit, 27-Sektoren- Bloecke unter der 64-KB-Treibergrenze, RAW_READ_INFO mit DiskOffset in 2048ern OBWOHL der Sektor 2352 hat, WAV-Kopf, AC/DC-sichere Namen). metadaten/musicbrainz.ts: DiscID nach musicbrainz.org-Rechnung — gegen die ECHTE AC/DC-Kennung getestet (3KVSIWn_…), Vorlauf wird beim Melden wieder HINZUgerechnet; Tags je Spur mit Bindewort-Kuenstlern. laufwerk/disc.ts: audioToc + audioSpurLesen ueber die LaufwerkApi. rip/iso.ts (§ 6.4): 1:1-Abbild fuer unbekannte Discs — async (ein 33-GB-Abbild darf den Kern nicht blockieren), sektor-ausgerichtet, unvollstaendig heisst FEHLER mit Byte-Zahlen, nie Schoenfaerberei. werkzeuge/schluessel.ts (§ 9.1): Dauerlizenz schlaegt Forum-Beta-Key (t=1053, T-Muster aus makemkv_key.py), Ablage in HKCU ueber reg.exe (kein zweiter koffi-Ort), GEFRAGT statt gerechnet (makemkvcon info disc:9999, MSG 5020/5021/5051), abgelehnter Key wird ZURUECKGENOMMEN (schlimmer als keiner: 0 Titel, kein Laufwerk), app_UpdateEnable nur- wenn-fehlt (der 4K-Schluesselkanal). Kern prueft beim Start und taeglich; Dashboard-Kachel zeigt Stand + letzten Pruefzeitpunkt; Dauerlizenz-Feld in den Einstellungen. Pipeline verzweigt nach Disc-Typ: cd → Sektoren→WAV→FLAC mit MusicBrainz-Tags (Ordner 'Kuenstler - Album', ohne Treffer ehrlich 'Track NN'), unknown → ISO. Bibliothek-Eintraege fuer beide. 181 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c78ff22ab7
commit
2d9cef22b3
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
|
||||||
|
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
|
||||||
|
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
|
||||||
|
// liegt roh in 2352-Byte-Sektoren.
|
||||||
|
//
|
||||||
|
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
|
||||||
|
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
|
||||||
|
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
|
||||||
|
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
|
||||||
|
|
||||||
|
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
|
||||||
|
export const SEKTOR_BYTES = 2352
|
||||||
|
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
|
||||||
|
export const OFFSET_EINHEIT = 2048
|
||||||
|
export const FRAMES_JE_SEKUNDE = 75
|
||||||
|
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
|
||||||
|
export const VORLAUF_FRAMES = 150
|
||||||
|
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
|
||||||
|
export const BLOCK_SEKTOREN = 27
|
||||||
|
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
|
||||||
|
export const CDDA = 2
|
||||||
|
|
||||||
|
export interface Spur {
|
||||||
|
nr: number
|
||||||
|
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
|
||||||
|
start: number
|
||||||
|
sektoren: number
|
||||||
|
audio: boolean
|
||||||
|
dauer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSF-Adresse → logische Sektornummer (ECMA-130). Wer die 150 vergisst,
|
||||||
|
* liest jeden Track zwei Sekunden zu früh. */
|
||||||
|
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
|
||||||
|
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dauerText(sektoren: number): string {
|
||||||
|
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
|
||||||
|
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Bytes von IOCTL_CDROM_READ_TOC → Spurliste. CDROM_TOC (ntddcdrm.h):
|
||||||
|
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
|
||||||
|
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke —
|
||||||
|
* ohne ihn fehlt der letzten Spur die Länge). */
|
||||||
|
export function tocZerlegen(rohdaten: Buffer): Spur[] {
|
||||||
|
if (rohdaten.length < 12) return []
|
||||||
|
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
|
||||||
|
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
|
||||||
|
const adrControl = rohdaten[stelle + 1]
|
||||||
|
const nummer = rohdaten[stelle + 2]
|
||||||
|
const m = rohdaten[stelle + 5]
|
||||||
|
const s = rohdaten[stelle + 6]
|
||||||
|
const f = rohdaten[stelle + 7]
|
||||||
|
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
|
||||||
|
}
|
||||||
|
const spuren: Spur[] = []
|
||||||
|
for (let i = 0; i < eintraege.length; i++) {
|
||||||
|
const eintrag = eintraege[i]
|
||||||
|
if (eintrag.nr === 0xaa) continue
|
||||||
|
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
|
||||||
|
const laenge = eintraege[i + 1].lba - eintrag.lba
|
||||||
|
if (laenge <= 0) continue
|
||||||
|
spuren.push({
|
||||||
|
nr: eintrag.nr,
|
||||||
|
start: eintrag.lba,
|
||||||
|
sektoren: laenge,
|
||||||
|
audio: eintrag.audio,
|
||||||
|
dauer: dauerText(laenge),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return spuren
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Track in Lese-Blöcke zerlegen — am Stück scheitert an der
|
||||||
|
* 64-KB-Grenze vieler Treiber. */
|
||||||
|
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
|
||||||
|
if (sektoren <= 0 || jeBlock <= 0) return []
|
||||||
|
const teile: Array<[number, number]> = []
|
||||||
|
let gelesen = 0
|
||||||
|
while (gelesen < sektoren) {
|
||||||
|
const anzahl = Math.min(jeBlock, sektoren - gelesen)
|
||||||
|
teile.push([start + gelesen, anzahl])
|
||||||
|
gelesen += anzahl
|
||||||
|
}
|
||||||
|
return teile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der 44-Byte-WAV-Kopf — CD-Audio IST schon PCM im WAV-Format, es fehlt
|
||||||
|
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
|
||||||
|
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
|
||||||
|
const byteRate = (rate * kanaele * bits) / 8
|
||||||
|
const block = (kanaele * bits) / 8
|
||||||
|
const kopf = Buffer.alloc(44)
|
||||||
|
kopf.write('RIFF', 0, 'ascii')
|
||||||
|
kopf.writeUInt32LE(36 + datenBytes, 4)
|
||||||
|
kopf.write('WAVE', 8, 'ascii')
|
||||||
|
kopf.write('fmt ', 12, 'ascii')
|
||||||
|
kopf.writeUInt32LE(16, 16)
|
||||||
|
kopf.writeUInt16LE(1, 20)
|
||||||
|
kopf.writeUInt16LE(kanaele, 22)
|
||||||
|
kopf.writeUInt32LE(rate, 24)
|
||||||
|
kopf.writeUInt32LE(byteRate, 28)
|
||||||
|
kopf.writeUInt16LE(block, 32)
|
||||||
|
kopf.writeUInt16LE(bits, 34)
|
||||||
|
kopf.write('data', 36, 'ascii')
|
||||||
|
kopf.writeUInt32LE(datenBytes, 40)
|
||||||
|
return kopf
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
|
||||||
|
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
|
||||||
|
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
|
||||||
|
const eingabe = Buffer.alloc(16)
|
||||||
|
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
|
||||||
|
eingabe.writeUInt32LE(anzahl, 8)
|
||||||
|
eingabe.writeUInt32LE(CDDA, 12)
|
||||||
|
return eingabe
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS —
|
||||||
|
* „AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
|
||||||
|
export function sauberName(text: string): string {
|
||||||
|
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
|
||||||
|
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateiname(nr: number, titel = ''): string {
|
||||||
|
const sauber = sauberName(titel)
|
||||||
|
const nummer = String(nr).padStart(2, '0')
|
||||||
|
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
|
||||||
|
* den Fortschritt selbst), Tags einzeln. */
|
||||||
|
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
|
||||||
|
const befehl = ['-8', '--totally-silent', '-o', ziel]
|
||||||
|
for (const [name, wert] of Object.entries(tags)) {
|
||||||
|
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
|
||||||
|
}
|
||||||
|
befehl.push(wav)
|
||||||
|
return befehl
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — die einzige echte Lücke
|
||||||
|
// gegenüber ARM: Wird eine Disc weder als Film noch als Musik erkannt,
|
||||||
|
// wird sie 1:1 gesichert statt abgelehnt. Sequenziell aus \\.\G: lesen;
|
||||||
|
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
|
||||||
|
// Bytes). Kein Fremdwerkzeug nötig.
|
||||||
|
//
|
||||||
|
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
|
||||||
|
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
|
||||||
|
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
|
||||||
|
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
|
||||||
|
// sequenziell ab 0.
|
||||||
|
import { open } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const BLOCK_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
export type IsoErgebnis =
|
||||||
|
| { status: 'success'; bytes: number }
|
||||||
|
| { status: 'cancelled' }
|
||||||
|
| { status: 'error'; fehler: string; bytes: number }
|
||||||
|
|
||||||
|
export async function isoSichern(
|
||||||
|
geraetepfad: string,
|
||||||
|
zielDatei: string,
|
||||||
|
erwarteteBytes: number,
|
||||||
|
aufFortschritt?: (prozent: number) => void,
|
||||||
|
abbruch?: AbortSignal,
|
||||||
|
): Promise<IsoErgebnis> {
|
||||||
|
if (erwarteteBytes <= 0) {
|
||||||
|
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
|
||||||
|
}
|
||||||
|
let quelle
|
||||||
|
try {
|
||||||
|
quelle = await open(geraetepfad, 'r')
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
let ziel
|
||||||
|
try {
|
||||||
|
ziel = await open(zielDatei, 'w')
|
||||||
|
} catch (fehler) {
|
||||||
|
await quelle.close()
|
||||||
|
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const puffer = Buffer.alloc(BLOCK_BYTES)
|
||||||
|
let gelesen = 0
|
||||||
|
let letzterProzent = -1
|
||||||
|
try {
|
||||||
|
while (gelesen < erwarteteBytes) {
|
||||||
|
if (abbruch?.aborted === true) return { status: 'cancelled' }
|
||||||
|
const rest = erwarteteBytes - gelesen
|
||||||
|
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
|
||||||
|
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
|
||||||
|
if (bytesRead <= 0) break
|
||||||
|
await ziel.write(puffer, 0, bytesRead)
|
||||||
|
gelesen += bytesRead
|
||||||
|
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
|
||||||
|
if (prozent !== letzterProzent) {
|
||||||
|
letzterProzent = prozent
|
||||||
|
aufFortschritt?.(prozent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
|
||||||
|
} finally {
|
||||||
|
await quelle.close()
|
||||||
|
await ziel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gelesen !== erwarteteBytes) {
|
||||||
|
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
|
||||||
|
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
|
||||||
|
bytes: gelesen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: 'success', bytes: gelesen }
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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:',
|
||||||
|
|||||||
Reference in New Issue
Block a user