Files
rippy/rippy-windows/src/kern/dienste/vorgaenge.ts
T
HitonabiandClaude Fable 5.1 3dc6798c79 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>
2026-09-02 17:51:15 +02:00

421 lines
18 KiB
TypeScript

// 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.',
})
}
}
}
}
}