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>
195 lines
8.0 KiB
TypeScript
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)
|
|
}
|
|
}
|