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>
179 lines
7.3 KiB
TypeScript
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)
|
|
}
|
|
}
|