feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2

WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 22:02:03 +02:00
co-authored by Claude Fable 5.1
parent d70a81d194
commit 3f7f962dff
29 changed files with 5102 additions and 1418 deletions
+538 -101
View File
@@ -3,19 +3,42 @@
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
//
// Seit 5.4.0 dazu: Vorgänge als Datensätze (welcher Roh-Ordner gehört zu
// welchem Film), die Kompressions-Warteschlange (das Laufwerk ist frei,
// sobald die Roh-Dateien liegen), das Roh-Aufräumen im Takt, die
// Wiederaufnahme nach einem Neustart, die Laufwerks-Prüfung aus dem
// Windows-Protokoll und die Nachprüfung der Erkennung nach dem Info-Lauf.
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type DiscInfoStand,
type KernNachricht,
type MetaVorschlag,
type VorgaengeStand,
type VorgangEintrag,
} from '../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
import { Pipeline } from './ablauf/pipeline'
import { KompressionsWarteschlange } from './ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from './ablage/rohaufraeumen'
import type { LaufwerkApi } from './laufwerk/codes'
import {
audioSpurLesen,
@@ -25,15 +48,17 @@ import {
kennung,
listOptischeLaufwerke,
} from './laufwerk/disc'
import { beurteilen, ereignisseLesen } from './laufwerk/ereignisse'
import { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { Datenbank, type Vorgang } from './speicher/db'
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
import { titelVorauswahl } from './rip/titelwahl'
import { sprachenFuerTitel } from './rip/parser'
import { strukturHinweis, titelVorauswahl } from './rip/titelwahl'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
@@ -72,6 +97,8 @@ if (pfad === null) {
}
}
const smokeKern = process.argv.includes('--smoke-kern')
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
@@ -83,6 +110,11 @@ function anHauptUndFenster(nachricht: KernNachricht): void {
anFenster(nachricht)
}
function hinweis(text: string): void {
console.log(`[kern] ${text}`)
anFenster({ art: 'kern-hinweis', text })
}
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
@@ -100,6 +132,39 @@ const discFingerprints = new Map<string, string>()
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
const discMerkmale = new Map<string, DiscTitel>()
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code, und jedes
* neue Feld hätte dreimal nachgezogen werden müssen. */
function discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = discMerkmale.get(id)
const meta = zuordnung?.meta
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: zuordnung === null ? null : (db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
}
}
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
@@ -109,28 +174,14 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
discNr: gefunden.discNr,
staffel: gefunden.staffel,
folgen: gefunden.folgen,
},
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung) })
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
@@ -183,43 +234,27 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
const zuordnung: Zuordnung = {
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta: {
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
backdropPfad: details?.backdrop_path ?? '',
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
meta,
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
discNr: discMerkmale.get(id)?.discNr ?? null,
staffel: discMerkmale.get(id)?.staffel ?? null,
folgen: discMerkmale.get(id)?.folgen ?? 0,
},
})
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
@@ -243,23 +278,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
discNr: null,
staffel: null,
folgen: 0,
},
})
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
}
}
},
@@ -277,9 +296,6 @@ const laufwerkeBereit: Promise<void> = (async () => {
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
@@ -343,12 +359,16 @@ async function titelLesen(id: string): Promise<void> {
stand: {
id,
laeuft: false,
titel: wahl.titel,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie
// zum Anhaken an.
titel: wahl.titel.map((t) => ({ ...t, sprachen: sprachenFuerTitel(info.streams.get(t.nr)) })),
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
},
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await erkennungNachpruefen(id, wahl, info.titel)
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
@@ -369,14 +389,59 @@ async function titelLesen(id: string): Promise<void> {
}
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
* „bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
async function erkennungNachpruefen(
id: string,
wahl: ReturnType<typeof titelVorauswahl>,
titel: Parameters<typeof strukturHinweis>[0],
): Promise<void> {
const bisher = discZuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = wahl.titel.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = discMerkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const nochmal = await zuordnen(merkmale.titel, tmdb, omdb, { istSerie: true, alternativen: merkmale.alternativen })
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
discZuordnungen.set(id, neu)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, neu) })
}
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
function rohWurzel(): string {
return join(ablageOrdner(), 'roh')
}
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
// Quelle (§ 6.7).
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePreset',
@@ -404,6 +469,274 @@ function geraetVon(id: string): string | null {
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
}
// ── Vorgänge (5.4.0): Datensatz je Rip, Warteschlange, Aufräumen ────────
function dateienAusJson(text: string): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
console.error(`[kern] fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
const ablageUmgebung: AblageUmgebung = {
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
}
},
}
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem
* Prozent-Tick. */
const letztePhase = new Map<string, string>()
const warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, ablageUmgebung, melden, abbruch),
(stand) => {
const l = stand.laeuft
if (l !== null && letztePhase.get(l.vorgangId) !== l.phase) {
letztePhase.set(l.vorgangId, l.phase)
db?.vorgangAktualisieren(l.vorgangId, { phase: l.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
vorgaengeSenden()
},
(auftrag, ergebnis) => {
letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') rohAufraeumenLauf()
else vorgaengeSenden()
},
)
function vorgaengeStand(): VorgaengeStand {
const regel = regelLesen(db?.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const liste = db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: warteschlange.stand() }
}
function vorgaengeSenden(): void {
try {
anHauptUndFenster({ art: 'vorgaenge', stand: vorgaengeStand() })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Vorgänge: ${String(fehler)}` })
}
}
function rohAufraeumenLauf(): void {
if (db === null) return
try {
const regel = regelLesen(db.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const kandidaten = db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: 0 })
hinweis(`Roh-Dateien von „${db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}` })
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Aufräumen: ${String(fehler)}` })
}
vorgaengeSenden()
}
function vorgangRohLoeschen(vorgangId: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.` })
return
}
if (!istUnterRohWurzel(rohWurzel(), v.rohOrdner)) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" liegt nicht unter ${rohWurzel()} — nicht angefasst.` })
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString(), rohBytes: 0 })
hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
function fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(rohWurzel(), ordner)) {
anFenster({ art: 'kern-fehler', text: `${ordner}" liegt nicht direkt unter ${rohWurzel()} — nicht angefasst.` })
return
}
const bekannt = (db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
anFenster({ art: 'kern-fehler', text: `${ordner}" gehört zu einem Vorgang — dort löschen.` })
return
}
try {
rmSync(ordner, { recursive: true, force: true })
hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `${ordner}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" ist schon in der Warteschlange.` })
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.` })
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).` })
return
}
if (vorhandene.length < auftrag.dateien.length) {
hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const neu = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner.
zielOrdner: v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) ? v.zielOrdner : '',
}
db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = warteschlange.einreihen(neu, v.titel)
hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
vorgaengeSenden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
function vorgaengeWiederaufnehmen(): void {
if (db === null) return
const jetzt = new Date().toISOString()
for (const v of db.vorgangListe()) {
if (v.phase === 'rippt') {
db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
@@ -425,37 +758,93 @@ const ripVerwaltung = new Pipeline({
},
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
melden: (status) => {
anHauptUndFenster({ art: 'rip-status', status })
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerkPruefen(status.id)
},
zuordnung: (id) => discZuordnungen.get(id) ?? null,
discAngaben: (id) => {
const m = discMerkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
},
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
bibliothekEintragen: ablageUmgebung.bibliothekEintragen,
vorgangBeginnt: (v) => {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
db?.vorgangAnlegen(vorgang)
vorgaengeSenden()
},
vorgangEinreihen: (auftrag, titel) => {
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = warteschlange.einreihen(auftrag, titel)
vorgaengeSenden()
return platz
},
vorgangEndet: (id, phase, text) => {
db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
vorgaengeSenden()
},
nachRipAuswerfen: (id) => {
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
if (db?.einstellungLesen('autoAuswurf') === 'false') return
void auswerfen(id)
},
})
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ───────────────────────────────
const GESUNDHEIT_STUNDEN = 24
async function laufwerkPruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
anFenster({ art: 'laufwerk-gesundheit', id, bericht: beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN) })
}
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
const ERLAUBTE_EINSTELLUNGEN = new Set([
@@ -479,6 +868,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
])
function einstellungenSenden(): void {
@@ -515,6 +905,25 @@ async function schluesselLauf(): Promise<void> {
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async function spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('./werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${stand.text}` })
} else if (stand.stand === 'gesetzt') {
hinweis(stand.text)
} else {
console.log(`[kern] ${stand.text}`)
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${String(fehler)}` })
}
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
@@ -702,7 +1111,7 @@ process.parentPort.on('message', (ereignis) => {
} else if (frage.art === 'titel-lesen') {
void titelLesen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? []))
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [], frage.optionen ?? {}))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'rip-ueberspringen') {
@@ -719,6 +1128,8 @@ process.parentPort.on('message', (ereignis) => {
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
// Eine neue Aufbewahrungs-Regel wirkt sofort auf die Anzeige.
if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaengeSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
@@ -730,6 +1141,22 @@ process.parentPort.on('message', (ereignis) => {
void makemkvUpdateLauf()
} else if (frage.art === 'schluessel-pruefen') {
void schluesselLauf().then(() => werkzeugeSenden())
} else if (frage.art === 'vorgaenge-laden') {
vorgaengeSenden()
} else if (frage.art === 'vorgang-roh-loeschen') {
vorgangRohLoeschen(frage.vorgangId)
} else if (frage.art === 'vorgang-neu-komprimieren') {
vorgangNeuKomprimieren(frage.vorgangId, frage.preset ?? '')
} else if (frage.art === 'kompression-abbrechen') {
if (!warteschlange.abbrechen(frage.vorgangId)) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.` })
}
} else if (frage.art === 'roh-aufraeumen') {
rohAufraeumenLauf()
} else if (frage.art === 'fremden-ordner-loeschen') {
fremdenOrdnerLoeschen(frage.ordner)
} else if (frage.art === 'laufwerk-pruefen') {
void laufwerkPruefen(frage.id)
}
})
fensterPort.start()
@@ -744,6 +1171,7 @@ process.parentPort.on('message', (ereignis) => {
anFenster({ art: 'schluessel-status', stand: schluesselStand })
}
})
vorgaengeSenden()
}
})
@@ -759,11 +1187,20 @@ function bereitNachricht(): KernNachricht {
process.parentPort.postMessage(bereitNachricht())
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
einstellungenSenden()
// Was nach dem letzten Beenden noch offen war (5.4.0).
vorgaengeWiederaufnehmen()
// 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') {
if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf()
void spurauswahlLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden()
}
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
// nach jeder gelungenen Kompression (siehe Warteschlange).
if (!smokeKern) {
setTimeout(() => rohAufraeumenLauf(), 60_000).unref()
setInterval(() => rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
}