Files
rippy/rippy-windows/src/kern/dienste/automatik.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

179 lines
7.3 KiB
TypeScript

// 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)
}
}