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="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||
</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 className="space-y-3">
|
||||
@@ -426,6 +433,7 @@ export default function App() {
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
@@ -535,6 +543,24 @@ export default function App() {
|
||||
: ['Ping unterwegs …']
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface Verbindung {
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string } | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
@@ -47,6 +48,7 @@ const stand: Verbindung = {
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
@@ -122,6 +124,10 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
|
||||
@@ -106,6 +106,10 @@ export type KernNachricht =
|
||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||
| {
|
||||
art: 'schluessel-status'
|
||||
stand: { quelle: string; angenommen: boolean | null; gueltigBis: string; geholtAm: string; text: string }
|
||||
}
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht =
|
||||
@@ -169,6 +173,8 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||||
case 'bibliothek':
|
||||
return Array.isArray(wert.eintraege)
|
||||
case 'schluessel-status':
|
||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function start(): void {
|
||||
}
|
||||
},
|
||||
() => statusSenden(),
|
||||
smokeModus,
|
||||
)
|
||||
|
||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||
|
||||
@@ -26,11 +26,16 @@ export class KernVerwaltung {
|
||||
private readonly datenbankPfad: string,
|
||||
private readonly beiNachricht: (nachricht: KernNachricht) => void,
|
||||
private readonly beiZustand: (zustand: KernZustand) => void,
|
||||
private readonly smokeModus = false,
|
||||
) {}
|
||||
|
||||
starten(): void {
|
||||
const kernDatei = join(__dirname, 'kern.js')
|
||||
const kern = utilityProcess.fork(kernDatei, [`--datenbank=${this.datenbankPfad}`], {
|
||||
const args = [`--datenbank=${this.datenbankPfad}`]
|
||||
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
|
||||
// die Schlüsselkette dann aus.
|
||||
if (this.smokeModus) args.push('--smoke-kern')
|
||||
const kern = utilityProcess.fork(kernDatei, args, {
|
||||
serviceName: 'Rippy-Kern',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
@@ -18,7 +18,13 @@ import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
|
||||
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
import { isoSichern } from '../rip/iso'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
abbruch: AbortController
|
||||
@@ -30,6 +36,18 @@ export interface PipelineUmgebung {
|
||||
makemkvcon: () => string
|
||||
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
|
||||
handbrake: () => string
|
||||
/** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */
|
||||
flac: () => string
|
||||
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
||||
audioToc?: (id: string) => Spur[]
|
||||
/** Rohe Audio-Sektoren lesen (W-7). */
|
||||
audioSpurLesen?: (
|
||||
id: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
) => number
|
||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||
geraet: (id: string) => GeraeteInfo | null
|
||||
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||
@@ -106,6 +124,21 @@ export class Pipeline {
|
||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
|
||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||
if (geraet.typ === 'cd') {
|
||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||
this.melden(this.laufend.get(id)!.status)
|
||||
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||
return
|
||||
}
|
||||
if (geraet.typ === 'unknown') {
|
||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
||||
this.melden(this.laufend.get(id)!.status)
|
||||
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||
return
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
|
||||
const start: RipStatus = {
|
||||
@@ -313,6 +346,170 @@ export class Pipeline {
|
||||
abbrechen(id: string): void {
|
||||
this.laufend.get(id)?.abbruch.abort()
|
||||
}
|
||||
|
||||
// ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ──
|
||||
private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise<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
|
||||
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
|
||||
import {
|
||||
audioSpurLesen,
|
||||
audioToc,
|
||||
auswerfenMitGrund,
|
||||
geraeteInfo,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
} from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
@@ -196,9 +203,25 @@ function einstellungenLaden(): Record<string, string> {
|
||||
return werte
|
||||
}
|
||||
|
||||
function geraetVon(id: string): string | null {
|
||||
if (laufwerkApi === null) return null
|
||||
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
||||
}
|
||||
|
||||
const ripVerwaltung = new Pipeline({
|
||||
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
|
||||
audioToc: (id) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioToc(laufwerkApi, geraet)
|
||||
},
|
||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
|
||||
const geraet = geraetVon(id)
|
||||
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
||||
},
|
||||
geraet: (id) => {
|
||||
if (laufwerkApi === null) return null
|
||||
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
||||
@@ -237,6 +260,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'makemkvAppKey',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
@@ -252,6 +276,29 @@ function einstellungenSenden(): void {
|
||||
anFenster({ art: 'einstellungen', werte })
|
||||
}
|
||||
|
||||
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||
let schluesselStand: Extract<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> {
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||
@@ -336,6 +383,9 @@ process.parentPort.on('message', (ereignis) => {
|
||||
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
|
||||
}
|
||||
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
|
||||
if (schluesselStand !== null) {
|
||||
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -350,3 +400,8 @@ function bereitNachricht(): KernNachricht {
|
||||
}
|
||||
|
||||
process.parentPort.postMessage(bereitNachricht())
|
||||
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
||||
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||
void schluesselLauf()
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ import {
|
||||
ERROR_NOT_READY,
|
||||
ERROR_NOT_SUPPORTED,
|
||||
ERROR_NO_MEDIA_IN_DRIVE,
|
||||
CDROM_TOC_BYTES,
|
||||
DRIVE_CDROM,
|
||||
GENERIC_READ,
|
||||
IOCTL_CDROM_DISK_TYPE,
|
||||
IOCTL_CDROM_RAW_READ,
|
||||
IOCTL_CDROM_READ_TOC,
|
||||
IOCTL_DISK_GET_LENGTH_INFO,
|
||||
IOCTL_STORAGE_CHECK_VERIFY2,
|
||||
IOCTL_STORAGE_EJECT_MEDIA,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
type LaufwerkApi,
|
||||
} from './codes'
|
||||
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
|
||||
import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
|
||||
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
|
||||
|
||||
@@ -179,6 +183,42 @@ export async function auswerfenMitGrund(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ─────────────────────────────
|
||||
/** Das Inhaltsverzeichnis einer Audio-CD — leere Liste, wenn keine drin.
|
||||
* Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */
|
||||
export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES)
|
||||
return tocZerlegen(roh)
|
||||
})
|
||||
}
|
||||
|
||||
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
||||
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
||||
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
||||
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
|
||||
export function audioSpurLesen(
|
||||
api: LaufwerkApi,
|
||||
geraet: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
): number {
|
||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
||||
const teile = bloecke(start, sektoren)
|
||||
let gelesen = 0
|
||||
for (let i = 0; i < teile.length; i++) {
|
||||
const [von, anzahl] = teile[i]
|
||||
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
||||
schreiben(daten)
|
||||
gelesen += daten.length
|
||||
fortschritt?.((i + 1) / teile.length)
|
||||
}
|
||||
return gelesen
|
||||
})
|
||||
}
|
||||
|
||||
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
|
||||
const ZUGRIFFS_GRUENDE: Record<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({
|
||||
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
|
||||
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||
flac: () => '',
|
||||
geraet: () => ({
|
||||
id: 'G',
|
||||
name: 'Laufwerk G:',
|
||||
@@ -141,6 +142,7 @@ describe('Pipeline', () => {
|
||||
const pipeline = new Pipeline({
|
||||
makemkvcon: () => 'x',
|
||||
handbrake: () => 'y',
|
||||
flac: () => '',
|
||||
geraet: () => ({
|
||||
id: 'G',
|
||||
name: 'Laufwerk G:',
|
||||
|
||||
Reference in New Issue
Block a user