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>
This commit is contained in:
Hitonabi
2026-09-12 21:40:07 +02:00
co-authored by Claude Opus 5
parent 783b8e4897
commit c1ee339446
17 changed files with 238 additions and 54 deletions
+8
View File
@@ -10,6 +10,10 @@ export interface NfoMeta {
genres?: readonly string[]
laufzeitMinuten?: number
quelle?: string
/** 5.7.0 (F23): die Kennung bei TMDb (Zahl) oder IMDb (tt…) — Jellyfin,
* Emby und Kodi lesen sie als <uniqueid> und müssen nicht mehr über
* Titel und Jahr raten (kodi.wiki NFO files: uniqueid type=tmdb|imdb). */
kennung?: { quelle: string; id: string }
}
function xml(wert: string): string {
@@ -40,6 +44,10 @@ export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | n
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
}
if (meta.kennung !== undefined && meta.kennung.id.length > 0) {
const typ = meta.kennung.id.startsWith('tt') ? 'imdb' : meta.kennung.quelle === 'omdb' ? 'imdb' : 'tmdb'
zeilen.push(` <uniqueid type="${typ}" default="true">${xml(meta.kennung.id)}</uniqueid>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
+9 -3
View File
@@ -18,7 +18,9 @@ import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
* gerippt), wird „(2)", „(3)" … angehängt statt zu überschreiben — 5.7.0
* (F24): vorher die ersten acht Zeichen der Vorgangskennung, also
* „Akira (1988) [bluray-2]", was niemandem etwas sagte. */
export function zielordner(
basis: string,
titel: string,
@@ -29,8 +31,12 @@ export function zielordner(
const name = sichererName(titel, jahr)
if (name.length === 0) return join(basis, jobId)
const ziel = join(basis, name)
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
return ziel
if (!existiert(ziel)) return ziel
for (let n = 2; n < 100; n++) {
const kandidat = join(basis, `${name} (${n})`)
if (!existiert(kandidat)) return kandidat
}
return join(basis, `${name} [${jobId.slice(0, 8)}]`)
}
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
+5 -1
View File
@@ -386,6 +386,7 @@ export async function komprimierenUndAblegen(
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle:
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
kennung: zuordnung === null || zuordnung.meta.id === null ? undefined : { quelle: zuordnung.meta.quelle, id: String(zuordnung.meta.id) },
},
anzeigeTitel,
zuordnung?.jahr ?? null,
@@ -397,7 +398,10 @@ export async function komprimierenUndAblegen(
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
// 5.7.0 (F21): Was mit dem Poster passiert ist, steht in den Hinweisen
// — vorher wurde die Antwort verschluckt (R4).
const posterErgebnis = await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
if (posterErgebnis.length > 0 && !posterErgebnis.startsWith('poster.jpg')) hinweise.push(posterErgebnis)
}
const server = String(einstellungen['mediaServer'] ?? '')
+14 -6
View File
@@ -72,14 +72,15 @@ export interface PipelineUmgebung {
flac: () => string
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
audioToc?: (id: string) => Spur[]
/** Rohe Audio-Sektoren lesen (W-7). */
/** Rohe Audio-Sektoren lesen (W-7) — seit 5.7.0 asynchron mit Abbruch (F30). */
audioSpurLesen?: (
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
) => number
abbruch?: AbortSignal,
) => Promise<number>
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
@@ -748,10 +749,17 @@ export class Pipeline {
const wav = join(arbeit, 'spur.wav')
const strom = createWriteStream(wav)
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
const s = this.laufend.get(id)?.status
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
})
await this.umgebung.audioSpurLesen(
id,
spur.start,
spur.sektoren,
(daten) => strom.write(daten),
(anteil) => {
const s = this.laufend.get(id)?.status
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
},
abbruch.signal,
)
await new Promise<void>((fertig, kaputt) => {
strom.end(() => fertig())
strom.on('error', kaputt)
+9 -1
View File
@@ -74,7 +74,15 @@ export class AutomatikDienst {
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
// 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 …".')
+4 -3
View File
@@ -141,10 +141,11 @@ export class LaufwerkDienst {
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
abbruch?: AbortSignal,
): Promise<number> {
const geraet = this.geraetVon(id)
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
if (this.api === null || geraet === null) return Promise.reject(new Error(`Laufwerk ${id}: gibt es nicht`))
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt, abbruch)
}
async auswerfen(id: string): Promise<void> {
+4 -1
View File
@@ -276,7 +276,10 @@ export class VorgangDienst {
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
// 5.7.0 (F27): Ein fertiger, UNkomprimierter Vorgang hat seine Roh-
// Dateien verschoben — da ist nichts mehr zu wiederholen.
neuKomprimierbar:
rohDa && ruhig && !(v.phase === 'fertig' && !v.komprimiert) && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
+25 -3
View File
@@ -21,20 +21,42 @@ export class WerkzeugDienst {
private readonly einstellungen: EinstellungsDienst,
) {}
// 5.7.0 (Durchsicht F22): Ein Fund gilt 60 s. Vorher lief bei fehlendem
// MakeMKV bei JEDEM Aufruf die Suchkette bis in die Registry (dreimal
// `reg query … /s`, synchron, bis 45 s) — und makemkvcon() wird oft
// gerufen: je Pipeline-Start, je Titel-Lauf, im Update-Lauf alle 15 s.
private gefunden = new Map<string, { pfad: string; eingestellt: string; wann: number }>()
private static readonly FUND_FRISCH_MS = 60_000
private suchen(name: string, schluessel: string): string {
const eingestellt = this.einstellungen.lesen(schluessel)
const alt = this.gefunden.get(name)
if (alt !== undefined && alt.eingestellt === eingestellt && Date.now() - alt.wann < WerkzeugDienst.FUND_FRISCH_MS) return alt.pfad
const pfad = finden(name, eingestellt)
this.gefunden.set(name, { pfad, eingestellt, wann: Date.now() })
return pfad
}
/** Nach einer Installation oder einem neuen Pfad: frisch suchen. */
neuSuchen(): void {
this.gefunden.clear()
}
makemkvcon(): string {
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
return this.suchen('makemkv', 'werkzeug.makemkv')
}
handbrake(): string {
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
return this.suchen('handbrake', 'werkzeug.handbrake')
}
flac(): string {
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
return this.suchen('flac', 'werkzeug.flac')
}
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
async senden(): Promise<void> {
this.neuSuchen()
const makemkvcon = this.makemkvcon()
const handbrake = this.handbrake()
const flac = this.flac()
+1 -1
View File
@@ -146,7 +146,7 @@ ripVerwaltung = new Pipeline({
handbrake: () => werkzeuge.handbrake(),
flac: () => werkzeuge.flac(),
audioToc: (id) => laufwerke.audioToc(id),
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt),
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt, abbruch) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt, abbruch),
geraet: (id) => laufwerke.geraetInfoVon(id),
ablage: () => einstellungen.ablageOrdner(),
rohWurzel: () => einstellungen.rohWurzel(),
@@ -34,19 +34,19 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
}
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
* HandBrake `--preset keine` und scheiterte. */
* Kette: Typ-Preset → EMPFEHLUNG (gemessen, Hardware zuerst) → CPU-DEFAULT.
* PRESET_KEINE wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als
* Preset-Namen liefern, sonst bekäme HandBrake `--preset keine` und
* scheiterte. (5.7.0, A5: das „allgemeine Preset" transcodePreset ist seit
* 5.5.0 aus den Pipeline-Einstellungen raus — hier stand es noch als
* toter Zweig.) */
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const eigen = String(einstellungen[schluessel] ?? '').trim()
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
}
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
if (allgemein.length > 0) return allgemein
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
return empfehlung.length > 0 && empfehlung !== PRESET_KEINE ? empfehlung : DEFAULT_HB_PRESET
}
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
+18 -6
View File
@@ -196,27 +196,39 @@ export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
export function audioSpurLesen(
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig.
* 5.7.0 (Durchsicht F30): Zwischen den Blöcken bekommt der Kern die
* Schleife zurück (setImmediate) — vorher blockierte eine ganze Spur den
* Kern, und „Abbrechen" wirkte erst zwischen zwei Spuren. `abbruch` endet
* den Lauf am nächsten Block. */
export async function audioSpurLesen(
api: LaufwerkApi,
geraet: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
abbruch?: AbortSignal,
): Promise<number> {
const handle = api.oeffnen(geraet, GENERIC_READ)
try {
const teile = bloecke(start, sektoren)
let gelesen = 0
for (let i = 0; i < teile.length; i++) {
if (abbruch?.aborted === true) throw new Error('abgebrochen')
const [von, anzahl] = teile[i]
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
const daten = api.steuern(handle, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
schreiben(daten)
gelesen += daten.length
fortschritt?.((i + 1) / teile.length)
// Alle acht Blöcke (~500 KB) einmal Luft holen — Nachrichten, Wache,
// Abbruch kommen dann durch.
if (i % 8 === 7) await new Promise<void>((weiter) => setImmediate(weiter))
}
return gelesen
})
} finally {
api.schliessen(handle)
}
}
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die