// 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 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() private staende = new Map() 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 { 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() /** 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() /** 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) } }