feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
+178
View File
@@ -0,0 +1,178 @@
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
//
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
// ist, passiert NICHTS von selbst — es sagt nur, warum.
//
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
// prüft den Ablauf mit einer Fake-Uhr.
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { ABGELOEST } from '../rip/makemkv'
import type { Zuordnung } from '../metadaten/zuordnung'
export const AUTOMATIK_SICHER = 0.9
export interface AutomatikAbhaengigkeiten {
an(): boolean
sekunden(): number
ripLaeuft(id: string): boolean
titelLesen(id: string): Promise<TitelStand>
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
benachrichtigen(titel: string, text: string): void
/** Uhr — injizierbar. */
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
taktStoppen?: (zeiger: NodeJS.Timeout) => void
}
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
return stand.titel
.filter((t) => t.gewaehlt)
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
}
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
* Merker; „ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
export function optionenAus(
angaben: { staffel: number | null; discNr: number | null } | null,
merker: { staffel: number; naechsteFolge: number } | null,
): RipAuftragOptionen {
const staffel = angaben?.staffel ?? merker?.staffel ?? null
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
return { staffel, ersteFolge }
}
export class AutomatikDienst {
private zeiger = new Map<string, NodeJS.Timeout>()
private staende = new Map<string, AutomatikStand>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: AutomatikAbhaengigkeiten,
) {}
stand(id: string): AutomatikStand | null {
return this.staende.get(id) ?? null
}
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
const stand = { id, aktiv, sekunden, text }
this.staende.set(id, stand)
this.kontext.anFenster({ art: 'automatik', stand })
}
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
const id = geraet.id
if (!this.abh.an()) return
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
if (this.abh.ripLaeuft(id)) return
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
return
}
if (schonGerippt !== null) {
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
return
}
this.melden(id, false, 0, 'Automatik: liest die Titel …')
const titel = await this.abh.titelLesen(id)
if (titel.fehler === ABGELOEST) return
if (this.abh.ripLaeuft(id)) return
if (titel.fehler.length > 0) {
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
return
}
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
return
}
this.countdown(id, titel)
}
private countdown(id: string, titel: TitelStand): void {
this.stopp(id, '')
let rest = this.abh.sekunden()
const n = titel.titel.filter((t) => t.gewaehlt).length
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
this.melden(id, true, rest, text(rest))
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
const zeiger = takt(() => {
rest -= 1
if (rest > 0) {
this.melden(id, true, rest, text(rest))
return
}
this.stopp(id, '')
this.starten(id, titel)
}, 1000)
this.zeiger.set(id, zeiger)
}
private starten(id: string, titel: TitelStand): void {
if (this.abh.ripLaeuft(id)) return
const auftrag = auftragAusVorschlag(titel)
if (auftrag.length === 0) return
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
this.abh.ripStarten(id, auftrag, optionen)
}
private merkerZuordnung = new Map<string, Zuordnung>()
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
if (zuordnung === null) this.merkerZuordnung.delete(id)
else this.merkerZuordnung.set(id, zuordnung)
}
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
const z = this.merkerZuordnung.get(id)
return z === undefined ? null : this.abh.serienMerker(z)
}
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
stopp(id: string, grund: string): void {
const zeiger = this.zeiger.get(id)
if (zeiger !== undefined) {
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
this.zeiger.delete(id)
}
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
if (stand === undefined || !stand.aktiv) return
this.stopp(id, '')
const titel = this.letzteTitel.get(id)
if (titel !== undefined) this.starten(id, titel)
}
private letzteTitel = new Map<string, TitelStand>()
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
titelMerken(id: string, stand: TitelStand): void {
this.letzteTitel.set(id, stand)
}
discEntnommen(id: string): void {
this.stopp(id, '')
this.staende.delete(id)
this.letzteTitel.delete(id)
this.merkerZuordnung.delete(id)
}
}
@@ -0,0 +1,94 @@
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
// discord.com/developers/docs/resources/webhook („Execute Webhook":
// content ist eine Zeichenkette bis 2000 Zeichen).
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
// der ihn ausgelöst hat.
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export interface Kanaele {
discordWebhook: string
telegramToken: string
telegramChatId: string
}
/** Was an Discord geht (pur, testbar). */
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
}
/** Was an Telegram geht (pur, testbar). */
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
return {
url: `https://api.telegram.org/bot${token}/sendMessage`,
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
}
}
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
return {
discordWebhook: lesen('discordWebhook').trim(),
telegramToken: lesen('telegramToken').trim(),
telegramChatId: lesen('telegramChatId').trim(),
}
}
export function kanaeleEingerichtet(k: Kanaele): boolean {
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
}
export class BenachrichtigungsDienst {
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly holen: typeof fetch = fetch,
) {}
eingeschaltet(): boolean {
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
}
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
if (!erzwingen && !this.eingeschaltet()) return []
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
const fehler: string[] = []
const anfragen: Array<{ name: string; url: string; body: string }> = []
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
}
for (const a of anfragen) {
try {
const antwort = await this.holen(a.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: a.body,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
} catch (e) {
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
}
}
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
return fehler
}
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
async testen(): Promise<void> {
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
if (!kanaeleEingerichtet(k)) {
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
return
}
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
}
}
@@ -0,0 +1,137 @@
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
// also wie bisher).
import { join } from 'node:path'
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
import { einstellung, type KernKontext } from '../kontext'
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'ablage',
'arbeitsordner',
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
'tmdbApiKey',
'omdbApiKey',
'makemkvAppKey',
'updateUrl',
'setup.done',
'autoAuswurf',
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
// 5.5.0
'automatik',
'automatikSekunden',
'benachrichtigungen',
'discordWebhook',
'telegramToken',
'telegramChatId',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
* frühere „Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus —
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
* verwirrt nur. */
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePresetDvd',
'transcodePresetBluray',
'transcodePresetUhd',
'audioSprachen',
'untertitelSprachen',
'mediaServer',
'mediaServerUrl',
'mediaServerApiKey',
] as const
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
export class EinstellungsDienst {
constructor(private readonly kontext: KernKontext) {}
lesen(schluessel: string, vorgabe = ''): string {
return einstellung(this.kontext, schluessel, vorgabe)
}
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
ablageOrdner(): string {
const eingestellt = this.lesen('ablage')
if (eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
arbeitsordnerEigen(): string {
return this.lesen('arbeitsordner').trim()
}
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
arbeitsordner(): string {
const eigen = this.arbeitsordnerEigen()
return eigen.length > 0 ? eigen : this.ablageOrdner()
}
/** Wo die Roh-Ordner liegen. */
rohWurzel(): string {
return join(this.arbeitsordner(), 'roh')
}
/** Was Pipeline und Ablage brauchen. */
fuerPipeline(): Record<string, string> {
const werte: Record<string, string> = {}
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
return werte
}
automatikAn(): boolean {
return this.lesen('automatik', 'an') !== 'aus'
}
automatikSekunden(): number {
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
}
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
senden(): void {
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
const wert = this.kontext.db?.einstellungLesen(schluessel)
if (wert !== null && wert !== undefined) werte[schluessel] = wert
}
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
}
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
setzen(schluessel: string, wert: string): boolean {
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
return false
}
if (this.kontext.db === null) {
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
return false
}
this.kontext.db.einstellungSchreiben(schluessel, wert)
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
this.senden()
return true
}
}
function istGeheim(schluessel: string): boolean {
return /key|token|webhook|passwort/i.test(schluessel)
}
+408
View File
@@ -0,0 +1,408 @@
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
// Hintergrund der Erst-Einrichtung.
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
MetaVorschlag,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { einstellung, type KernKontext } from '../kontext'
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
ripLaeuft(id: string): boolean
geraetInfoVon(id: string): GeraeteInfo | null
makemkvcon(): string
}
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
export interface SerienMerker {
titel: string
staffel: number
naechsteFolge: number
wann: string
}
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
if (text === null || text.length === 0) return {}
try {
const roh: unknown = JSON.parse(text)
if (typeof roh !== 'object' || roh === null) return {}
const ergebnis: Record<string, SerienMerker> = {}
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
if (typeof wert !== 'object' || wert === null) continue
const m = wert as Record<string, unknown>
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
ergebnis[id] = {
titel: typeof m.titel === 'string' ? m.titel : '',
staffel: m.staffel,
naechsteFolge: m.naechsteFolge,
wann: typeof m.wann === 'string' ? m.wann : '',
}
}
return ergebnis
} catch {
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
return {}
}
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
export class ErkennungDienst {
private zuordnungen = new Map<string, Zuordnung>()
private fingerprints = new Map<string, string>()
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
private merkmale = new Map<string, DiscTitel>()
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
readonly letzteTitel = new Map<string, TitelStand>()
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
private titelAbbrueche = new Map<string, AbortController>()
private titelLaufZaehler = 0
private titelLaufNummern = new Map<string, number>()
constructor(
private readonly kontext: KernKontext,
private readonly abh: ErkennungAbhaengigkeiten,
) {}
private tmdb(): TmdbClient {
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
}
private omdb(): OmdbClient {
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
}
zuordnung(id: string): Zuordnung | null {
return this.zuordnungen.get(id) ?? null
}
fingerprint(id: string): string | null {
return this.fingerprints.get(id) ?? null
}
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
const m = this.merkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
}
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
private alleMerker(): Record<string, SerienMerker> {
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
}
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
if (tmdbId === null) return null
return this.alleMerker()[String(tmdbId)] ?? null
}
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
if (this.kontext.db === null) return
const alle = this.alleMerker()
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
}
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code. */
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = this.merkmale.get(id)
const meta = zuordnung?.meta
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
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 : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.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 ?? [],
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
}
}
schonGerippt(id: string): string | null {
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
}
/** § 5 Schritt 3: Disc rein → was ist das? Liefert die Zuordnung (für die
* Automatik) — null, wenn die Erkennung scheiterte. */
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
try {
const gefunden = discTitel(geraet.pfad)
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
this.merkmale.set(geraet.id, gefunden)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// 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, this.tmdb(), this.omdb(), {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
this.zuordnungen.set(geraet.id, zuordnung)
this.kontext.protokoll.schreiben(
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
return zuordnung
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
return null
}
}
discVergessen(id: string): void {
this.zuordnungen.delete(id)
this.fingerprints.delete(id)
this.merkmale.delete(id)
this.letzteTitel.delete(id)
this.titelAbbrueche.get(id)?.abort()
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
}
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
async metadatenSuchen(id: string, text: string): Promise<void> {
try {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
return
}
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
const vorschlaege: MetaVorschlag[] = [
...(filme ?? []).slice(0, 6).map((f) => ({
typ: 'movie' as const,
tmdbId: f.id,
titel: f.title ?? '',
jahr: jahrAus(f.release_date),
posterPfad: f.poster_path ?? '',
})),
...(serien ?? []).slice(0, 6).map((s) => ({
typ: 'tv' as const,
tmdbId: s.id,
titel: s.name ?? '',
jahr: jahrAus(s.first_air_date),
posterPfad: s.poster_path ?? '',
})),
].filter((v) => v.titel.length > 0)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
} catch (fehler) {
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = this.tmdb()
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: 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,
}
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
}
}
// ── 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
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
async titelLesen(id: string): Promise<TitelStand> {
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
const senden = (stand: TitelStand): TitelStand => {
if (!stand.laeuft) this.letzteTitel.set(id, stand)
this.kontext.anFenster({ art: 'disc-titel', stand })
return stand
}
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
const geraet = this.abh.geraetInfoVon(id)
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
const programm = this.abh.makemkvcon()
if (programm.length === 0) {
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
}
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
this.titelAbbrueche.get(id)?.abort()
const abbruch = new AbortController()
this.titelAbbrueche.set(id, abbruch)
const meineNummer = ++this.titelLaufZaehler
this.titelLaufNummern.set(id, meineNummer)
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
senden({ ...leer, laeuft: true })
const start = Date.now()
try {
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
const merkmale = this.merkmale.get(id)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0,
})
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
this.kontext.protokoll.schreiben(
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
)
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
laeuft: false,
titel,
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 this.nachpruefen(id, titel, info.titel)
return stand
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
const grund = String(fehler).includes('keine Antwort nach')
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
: `Titel lesen: ${String(fehler).slice(0, 200)}`
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
return senden({ ...leer, fehler: grund })
} finally {
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
if (nochAktuell()) {
this.titelAbbrueche.delete(id)
this.titelLaufNummern.delete(id)
}
}
}
/** 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. */
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
const bisher = this.zuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = vorschlag.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 = this.merkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.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 }
this.zuordnungen.set(id, neu)
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')}${Math.round(neu.confidence * 100)} %`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
}
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
async posterLaden(): Promise<void> {
const tmdb = this.tmdb()
if (!tmdb.verfuegbar) {
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
return
}
const pfade = await tmdb.beliebtePoster(40)
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
}
}
+202
View File
@@ -0,0 +1,202 @@
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
// Klick-Beweises.
//
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { LaufwerkApi } from '../laufwerk/codes'
import {
audioSpurLesen,
audioToc,
auswerfenMitGrund,
geraeteInfo,
kennung,
listOptischeLaufwerke,
} from '../laufwerk/disc'
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
import { DiscWache } from '../laufwerk/wache'
import type { Spur } from '../rip/audiocd'
const GESUNDHEIT_STUNDEN = 24
export interface LaufwerkHorcher {
discEingelegt(geraet: GeraeteInfo): void
discEntnommen(geraet: GeraeteInfo): void
}
export class LaufwerkDienst {
private api: LaufwerkApi | null = null
private wache: DiscWache | null = null
fehler: string | null = null
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
* Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
readonly fake: string
readonly bereit: Promise<void>
constructor(
private readonly kontext: KernKontext,
private readonly horcher: LaufwerkHorcher,
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
private readonly ripLaeuft: (id: string) => boolean,
umgebung: NodeJS.ProcessEnv = process.env,
) {
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
this.bereit = this.starten()
}
private fakeGeraet(): GeraeteInfo {
return {
id: this.fake,
name: `Laufwerk ${this.fake}:`,
typ: 'bluray',
pfad: `\\\\.\\${this.fake}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
}
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
this.horcher.discEingelegt(ereignis.geraet)
} else if (ereignis.art === 'disc-entnommen') {
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
this.horcher.discEntnommen(ereignis.geraet)
} else if (ereignis.art === 'laufwerk-weg') {
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
}
}
}
private async starten(): Promise<void> {
const wacheFehler = (fehler: unknown): void => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
}
try {
if (this.fake.length > 0) {
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
this.wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('../laufwerk/win32')
this.api = win32Api()
this.wache = new DiscWache(
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
(m) => this.wacheMeldung(m),
wacheFehler,
)
this.wache.starten()
} catch (fehler) {
this.fehler = String(fehler)
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
console.error(`[kern] Laufwerke: ${this.fehler}`)
}
}
aktuellerStand(): GeraeteInfo[] {
return this.wache?.aktuellerStand() ?? []
}
/** Der Geräte-Name zu einer Kennung — oder null. */
geraetVon(id: string): string | null {
if (this.api === null) return null
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
}
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
geraetInfoVon(id: string): GeraeteInfo | null {
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
const geraet = this.geraetVon(id)
if (geraet === null || this.api === null) return null
return geraeteInfo(this.api, geraet)
}
audioToc(id: string): Spur[] {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioToc(this.api, geraet)
}
audioSpurLesen(
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
}
async auswerfen(id: string): Promise<void> {
await this.bereit
if (this.ripLaeuft(id)) {
this.kontext.anFenster({
art: 'auswurf-ergebnis',
id,
grund: 'abgelehnt',
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
})
return
}
if (this.api === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
return
}
const geraet = this.geraetVon(id)
if (geraet === null) {
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
this.wache?.runde()
}
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
async pruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
this.kontext.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
}
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil}${bericht.text}`)
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
}
}
@@ -0,0 +1,87 @@
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
// Laufwerk gibt es trotzdem.
import { existsSync, statfsSync } from 'node:fs'
import { dirname } from 'node:path'
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import type { EinstellungsDienst } from './einstellungen'
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
const echtMessen: Messen = (pfad) => {
const s = statfsSync(pfad)
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
}
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
let p = pfad
for (let i = 0; i < 40; i++) {
if (existiert(p)) return p
const eltern = dirname(p)
if (eltern === p) return p
p = eltern
}
return p
}
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
try {
const s = messen(vorhandenerPfad(pfad, existiert))
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
} catch (fehler) {
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
// Fenster zeigt den Grund, nie eine erfundene Null.
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
}
}
export { platzUrteil } from '../../gemeinsam/platz'
export class SpeicherDienst {
private zeiger: NodeJS.Timeout | null = null
private letzter: SpeicherStand | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly messen: Messen = echtMessen,
) {}
stand(): SpeicherStand {
const arbeit = this.einstellungen.arbeitsordner()
const ablage = this.einstellungen.ablageOrdner()
const s: SpeicherStand = {
arbeit: platzMessen(arbeit, this.messen),
ablage: platzMessen(ablage, this.messen),
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
}
this.letzter = s
return s
}
letzterStand(): SpeicherStand | null {
return this.letzter
}
senden(): void {
try {
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
}
}
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
taktStarten(ms = 60_000): void {
if (this.zeiger !== null) return
this.zeiger = setInterval(() => this.senden(), ms)
this.zeiger.unref?.()
}
}
+420
View File
@@ -0,0 +1,420 @@
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
// Benachrichtigung, wenn eine Kompression zu Ende ist.
import { existsSync, rmSync } from 'node:fs'
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
import type { VorgangBeginn } from '../ablauf/pipeline'
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from '../ablage/rohaufraeumen'
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import type { KernKontext } from '../kontext'
import { TmdbClient } from '../metadaten/tmdb'
import type { Vorgang } from '../speicher/db'
import type { BenachrichtigungsDienst } from './benachrichtigung'
import type { EinstellungsDienst } from './einstellungen'
import type { ErkennungDienst } from './erkennung'
import type { WerkzeugDienst } from './werkzeuge'
/** Statistik aus den Vorgängen (pur). */
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
let fertige = 0
let rohBytes = 0
let fertigBytes = 0
let dauerS = 0
for (const v of liste) {
rohBytes += v.rohBytes
if (v.phase !== 'fertig') continue
fertige += 1
fertigBytes += v.fertigBytes
if (v.beendet !== null) {
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
if (Number.isFinite(d) && d > 0) dauerS += d
}
}
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
}
export function dateienAusJson(text: string, melden?: (grund: string) => void): 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.
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
export class VorgangDienst {
readonly warteschlange: KompressionsWarteschlange
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
private letztePhase = new Map<string, string>()
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
private readonly werkzeuge: WerkzeugDienst,
private readonly erkennung: ErkennungDienst,
private readonly benachrichtigung: BenachrichtigungsDienst,
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
private readonly speicherSenden: () => void,
) {
this.warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
(stand) => this.standGeaendert(stand.laeuft),
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
)
}
private get db() {
return this.kontext.db
}
ablageUmgebung(): AblageUmgebung {
return {
handbrake: () => this.werkzeuge.handbrake(),
ablage: () => this.einstellungen.ablageOrdner(),
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
einstellungen: () => this.einstellungen.fuerPipeline(),
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(this.einstellungen.lesen('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) => this.bibliothekEintragen(eintrag),
}
}
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
try {
this.db?.bibliothekEintragen(eintrag)
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
}
}
private standGeaendert(laeuft: KompressionStand | null): void {
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
this.senden()
}
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
this.letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
this.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 = this.db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status}${text}`)
this.kontext.anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
const letzte = Math.max(...ergebnis.folgenVergeben)
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
}
if (ergebnis.status !== 'abgebrochen') {
void this.benachrichtigung.senden(
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
`${titel}: ${ergebnis.text.slice(0, 300)}`,
)
}
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
else this.senden()
}
// ── Pipeline-Haken ──────────────────────────────────────────────────────
vorgangBeginnt(v: VorgangBeginn): void {
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: '[]',
}
this.db?.vorgangAnlegen(vorgang)
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk}${v.rohOrdner}`)
this.senden()
}
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
this.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 = this.warteschlange.einreihen(auftrag, titel)
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
this.senden()
return platz
}
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
this.senden()
}
// ── Stand fürs Fenster ──────────────────────────────────────────────────
stand(): VorgaengeStand {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const liste = this.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 && !this.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(this.einstellungen.rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return {
vorgaenge,
rohGesamtBytes,
fremde,
regel: regelText(regel),
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
statistik: statistikAus(liste),
}
}
senden(): void {
try {
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
} catch (fehler) {
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
}
this.speicherSenden()
}
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
pause(an: boolean): void {
this.warteschlange.pausieren(an)
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
this.senden()
}
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
rohAufraeumenLauf(): void {
if (this.db === null) return
try {
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
const kandidaten = this.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, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
} catch (fehler) {
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
}
this.senden()
}
rohLoeschen(vorgangId: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
return
}
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
// direkt unter irgendeinem roh\ ist.
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
this.kontext.fehler(`${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
return
}
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
this.kontext.fehler(`${ordner}" gehört zu einem Vorgang — dort löschen.`)
return
}
try {
rmSync(ordner, { recursive: true, force: true })
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
this.kontext.fehler(`${ordner}" nicht gelöscht: ${String(fehler)}`)
}
this.senden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
neuKomprimieren(vorgangId: string, preset: string): void {
const v = this.db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
return
}
if (this.warteschlange.kennt(vorgangId)) {
this.kontext.fehler(`${v.titel}" ist schon in der Warteschlange.`)
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
this.kontext.fehler(`${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) {
this.kontext.fehler(`${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
return
}
if (vorhandene.length < auftrag.dateien.length) {
this.kontext.hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
const neu: KompressionsAuftrag = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
zielOrdner: alteFassung ? v.zielOrdner : '',
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
}
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = this.warteschlange.einreihen(neu, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
this.senden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
wiederaufnehmen(): void {
if (this.db === null) return
const jetzt = new Date().toISOString()
for (const v of this.db.vorgangListe()) {
if (v.phase === 'rippt') {
this.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.',
})
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
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))) {
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
this.kontext.hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
this.db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
import type { KernNachricht } from '../../gemeinsam/nachrichten'
import type { KernKontext } from '../kontext'
import { finden } from '../werkzeuge/katalog'
import type { EinstellungsDienst } from './einstellungen'
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
export class WerkzeugDienst {
/** Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
schluesselStand: SchluesselStand | null = null
private beschaffungLaeuft = false
private installerAntwort: ((fehler: string | null) => void) | null = null
constructor(
private readonly kontext: KernKontext,
private readonly einstellungen: EinstellungsDienst,
) {}
makemkvcon(): string {
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
}
handbrake(): string {
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
}
flac(): string {
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
}
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
async senden(): Promise<void> {
const makemkvcon = this.makemkvcon()
const handbrake = this.handbrake()
const flac = this.flac()
const { encoderMessen } = await import('../komprimieren/encoder')
const gemessen = await encoderMessen(handbrake)
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
this.kontext.anFenster({
art: 'werkzeuge',
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
})
this.kontext.protokoll.schreiben(
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
)
}
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
async schluesselLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { schluesselKette } = await import('../werkzeuge/schluessel')
this.schluesselStand = await schluesselKette(
this.makemkvcon(),
this.einstellungen.lesen('makemkvAppKey'),
new Date().toISOString(),
)
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
} catch (fehler) {
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
}
}
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async 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') {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
} else if (stand.stand === 'gesetzt') {
this.kontext.hinweis(stand.text)
} else {
this.kontext.protokoll.schreiben(stand.text)
}
} catch (fehler) {
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
}
}
/** Antwort des Haupts auf 'installer-starten'. */
installerErgebnis(fehler: string | null): void {
this.installerAntwort?.(fehler ?? '')
this.installerAntwort = null
}
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
}
// ── 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
// Prozess-Leine und überlebt ein geschlossenes Rippy).
async makemkvUpdateLauf(): Promise<void> {
if (this.beschaffungLaeuft) {
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
return
}
this.beschaffungLaeuft = true
try {
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
'../werkzeuge/beschaffen'
)
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
const neueste = await makemkvNeuesteVersion()
if (neueste.length === 0) {
this.beschaffungMelden(
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
null,
false,
true,
)
this.beschaffungLaeuft = false
return
}
const installiert = this.schluesselStand?.version ?? ''
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
// Download derselben Fassung würde nichts lösen.
this.beschaffungMelden(
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
'bis dahin rippt der Beta-Modus weiter.',
null,
false,
)
this.beschaffungLaeuft = false
return
}
// Merken, woran eine gelungene Installation zu erkennen ist: Die
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
// erstmals eine).
const alteExe = this.makemkvcon()
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
const startFehler = await new Promise<string | null>((fertig) => {
this.installerAntwort = fertig
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
})
this.installerAntwort = null
if (startFehler !== null && startFehler.length > 0) {
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
this.beschaffungLaeuft = false
return
}
this.beschaffungMelden(
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
null,
true,
)
// Warten, bis die Installation durch ist: Die Versions-Ressource der
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
let runden = 0
const zeiger = setInterval(() => {
runden += 1
const exe = this.makemkvcon()
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
void this.schluesselLauf().then(() => this.senden())
} else if (runden >= 80) {
clearInterval(zeiger)
this.beschaffungLaeuft = false
this.beschaffungMelden(
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
null,
false,
)
}
}, 15_000)
zeiger.unref()
} catch (fehler) {
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
this.beschaffungLaeuft = false
}
}
}