Files
rippy/rippy-windows/src/kern/dienste/automatik.ts
T
HitonabiandClaude Opus 5 c1ee339446 fix(v5): Kleinkram der Durchsicht — Rückfragen vor Beenden und Löschen, Enter speichert, Escape schließt, ein Toast je Disc, NFO mit Kennung, „(2)" statt Kennungsstumpf, Werkzeug-Suche gemerkt, CD-Lesen ohne Blockade
WAS (je Fund der Durchsicht vom 12.09.2026):
- F15 Tray „Beenden" fragt nach, wenn ein Rip oder eine Kompression läuft
  (app.quit reißt makemkvcon/HandBrake über die Leine mit).
- F17 „Roh-Dateien löschen" und „Ordner löschen" fragen im Fenster nach
  (Größe und Pfad stehen dabei) — ein Klick war vorher 30 GB.
- F18 Eingabefelder speichern auch bei Enter und beim Verschwinden des
  Feldes (Bereichswechsel) — Tipparbeit ging vorher verloren.
- F19 Dialoge: Escape schließt, role=dialog/aria-modal, Fokus im Dialog.
- F20 Toast „Disc erkannt" einmal je Disc und Titel, nicht dreimal.
- F21 Was mit dem Poster passiert, steht in den Hinweisen (R4).
- F22 Werkzeug-Funde gelten 60 s; ein neuer Pfad und „werkzeuge-laden"
  suchen frisch — vorher bei fehlendem MakeMKV je Aufruf dreimal
  reg query /s (bis 45 s, synchron).
- F23 movie.nfo/tvshow.nfo tragen <uniqueid type=tmdb|imdb>.
- F24 Doppelter Zielordner heißt „Akira (1988) (2)" statt „[bluray-2]".
- F25 Umlaute in drei Knopftexten.
- F26 Phase „rettet" zählt im Haupt als laufender Vorgang (Update-Sperre,
  Taskleiste).
- F27 „Neu komprimieren" nicht mehr für verschobene, unkomprimierte
  Vorgänge.
- F28 Die Automatik sagt bei Audio-CD und unbekannter Disc, warum sie
  nichts tut.
- F30 Audio-CD-Spuren werden asynchron gelesen (alle acht Blöcke Luft),
  „Abbrechen" wirkt mitten in der Spur.
- A5 Der tote Schlüssel transcodePreset ist aus presetFuer raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:40:07 +02:00

195 lines
8.0 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
// 5.7.0 (F28): nicht stumm — der Kasten sagt, warum nichts passiert.
if (geraet.typ === 'cd') {
this.melden(id, false, 0, 'Automatik: Audio-CDs startet Rippy nicht von selbst — „Rippen" liest sie als FLAC.')
return
}
if (geraet.typ === 'unknown') {
this.melden(id, false, 0, 'Automatik wartet: Disc-Typ unbekannt — „Rippen" liest die Titel, dann entscheidest du.')
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)
}
/** Der Kasten in der Kachel verschwindet — ein Rip hat begonnen (5.6.0):
* Countdown weg, Text weg. Ohne das stand „Automatik gestoppt — du
* entscheidest." noch unter dem fertigen Rip. */
leeren(id: string): void {
this.stopp(id, '')
if (this.staende.has(id)) this.melden(id, false, 0, '')
}
/** „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)
}
}