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
+39 -3
View File
@@ -375,6 +375,23 @@ export function Feld({
}) {
const [entwurf, setEntwurf] = useState(wert)
useEffect(() => setEntwurf(wert), [wert])
// 5.7.0 (Durchsicht F18): Gespeichert wird beim Verlassen, bei Enter UND
// wenn das Feld verschwindet (Bereichswechsel, Dialog zu) — vorher ging
// Tipparbeit verloren, wenn man statt zu klicken Enter drückte oder in
// einen anderen Bereich wechselte. Die Refs tragen den jüngsten Stand in
// die Aufräum-Funktion des Effekts.
const juengster = useRef({ entwurf, wert, aufAendern })
juengster.current = { entwurf, wert, aufAendern }
useEffect(
() => () => {
const j = juengster.current
if (j.entwurf !== j.wert) j.aufAendern(j.entwurf)
},
[],
)
const speichern = (): void => {
if (entwurf !== wert) aufAendern(entwurf)
}
return (
<label className="block">
<span className="text-xs font-semibold text-dunst">{name}</span>
@@ -384,8 +401,9 @@ export function Feld({
value={entwurf}
placeholder={platzhalter}
onChange={(e) => setEntwurf(e.target.value)}
onBlur={() => {
if (entwurf !== wert) aufAendern(entwurf)
onBlur={speichern}
onKeyDown={(e) => {
if (e.key === 'Enter') speichern()
}}
className={FELD_KLASSE}
/>
@@ -465,10 +483,28 @@ export function DialogRahmen({
children: React.ReactNode
}) {
const hintergrund = useHintergrundKlick(schliessen)
// 5.7.0 (Durchsicht F19): Escape schließt, der Dialog ist als solcher
// ausgezeichnet (role/aria-modal) und bekommt den Fokus — ohne das blieb
// die Tastatur auf der Seite dahinter, und Escape tat nichts.
const karte = useRef<HTMLDivElement>(null)
useEffect(() => {
const taste = (e: KeyboardEvent): void => {
if (e.key === 'Escape') schliessen()
}
window.addEventListener('keydown', taste)
// Fokus in den Dialog — aber nicht aus einem Eingabefeld heraus, das
// ein Kind schon per autoFocus genommen hat.
if (document.activeElement === null || document.activeElement === document.body) karte.current?.focus()
return () => window.removeEventListener('keydown', taste)
}, [schliessen])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
<div
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
ref={karte}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60 focus:outline-none`}
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -198,6 +198,9 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
const v = verbindung.vorgaenge
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
const presets = verbindung.werkzeuge?.presets ?? []
const regelWert = werte['rohAufbewahrung'] ?? '7'
const phasenText: Record<VorgangEintrag['phase'], string> = {
@@ -312,11 +315,28 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
</button>
</>
)}
{!e.rohGeloescht && ruhig && (
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
Roh-Dateien löschen
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
Roh-Dateien löschen
</button>
)}
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
<button
onClick={() => {
setLoeschfrage(null)
vorgangRohLoeschen(e.id)
}}
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
>
Ja, löschen
</button>
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
Nein
</button>
</span>
)}
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Ordner öffnen
@@ -348,9 +368,27 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
{f.ordner} · {gb(f.bytes)}
</span>
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
Löschen
</button>
{loeschfrage !== f.ordner ? (
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
Löschen
</button>
) : (
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
wirklich?
<button
onClick={() => {
setLoeschfrage(null)
fremdenOrdnerLoeschen(f.ordner)
}}
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
>
Ja
</button>
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
Nein
</button>
</span>
)}
</div>
))}
</div>
@@ -159,7 +159,7 @@ export function LaufwerkKachel({
onClick={aufRippen}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
@@ -411,9 +411,9 @@ export function LaufwerkKachel({
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
>
Titel ueberspringen
Titel überspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
+33 -3
View File
@@ -128,6 +128,11 @@ function start(): void {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
// 5.7.0 (F20): einmal je Disc und Titel — die Nachprüfung nach dem
// Info-Lauf und „Ändern …" schicken denselben Stand erneut.
const schluessel = `${nachricht.info.id}:${nachricht.info.titel}`
if (letzterErkennungsToast.get(nachricht.info.id) === schluessel) return
letzterErkennungsToast.set(nachricht.info.id, schluessel)
toast(
'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
@@ -148,6 +153,8 @@ function start(): void {
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null
/** Je Laufwerk der zuletzt gemeldete Titel — gegen doppelte Toasts (F20). */
const letzterErkennungsToast = new Map<string, string>()
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
@@ -160,7 +167,8 @@ function start(): void {
* die Kompression — sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
// 5.7.0 (F26): Das Rettungs-Abbild ist ein laufender Vorgang wie der Rip.
const rip = letzterRip !== null && (letzterRip.phase === 'rippt' || letzterRip.phase === 'rettet') ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
@@ -184,7 +192,7 @@ function start(): void {
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
if (status.phase === 'rippt' || status.phase === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id)
} else {
aktiveVorgaenge.delete(status.id)
@@ -283,6 +291,28 @@ function start(): void {
app.quit()
}
/** „Beenden" im Tray (5.7.0, Durchsicht F15): Läuft ein Rip oder eine
* Kompression, fragt Rippy nach — app.quit() reißt über die Leine
* makemkvcon und HandBrake mit, und vierzig Minuten Rip wären weg. */
function beendenMitRueckfrage(): void {
if (aktiveVorgaenge.size === 0) {
beenden()
return
}
const laufend = kompressionStand !== null ? `die Kompression von „${kompressionStand.titel}"` : 'ein Rip'
const wahl = dialog.showMessageBoxSync({
type: 'warning',
title: 'Rippy beenden?',
message: `Gerade läuft ${laufend}.`,
detail: 'Beim Beenden bricht Rippy alles Laufende ab — der verlustfreie Rip und die halbe Kompression gehen verloren. Fenster schließen lässt Rippy im Infobereich weiterarbeiten.',
buttons: ['Weiterlaufen lassen', 'Trotzdem beenden'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
if (wahl === 1) beenden()
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt.
@@ -392,7 +422,7 @@ function start(): void {
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
trayErstellen(fensterZeigen, beendenMitRueckfrage)
}
fenster = fensterErstellen(app.getPath('userData'))
fensterVerdrahten()
+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
+9 -4
View File
@@ -144,16 +144,21 @@ describe('AutomatikDienst', () => {
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
expect(a.gestartet).toHaveLength(0)
})
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
it('aus oder laufender Rip: gar nichts — CD und unbekannte Disc sagen, warum (5.7.0, F28)', async () => {
const a = aufbau()
a.setAn(false)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.setAn(true)
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
a.setRipLaeuft(true)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(0)
a.setRipLaeuft(false)
a.setAn(true)
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
expect(a.nachrichten).toHaveLength(2)
expect(a.nachrichten[0].text).toContain('Audio-CD')
expect(a.nachrichten[1].text).toContain('unbekannt')
expect(a.gestartet).toHaveLength(0)
})
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
const a = aufbau()
+6 -3
View File
@@ -171,11 +171,14 @@ describe('presets', () => {
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
expect(komprimierenFuer('bluray', {})).toBe(true)
})
it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => {
it('presetFuer: Typ-Preset → Empfehlung → Default, nie „keine" (5.7.0: kein allgemeines Preset mehr, A5)', () => {
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein')
// Das alte „allgemeine Preset" ist seit 5.5.0 nicht mehr Teil der
// Pipeline-Einstellungen — es darf nichts mehr übersteuern.
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe(DEFAULT_HB_PRESET)
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A')
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE }, 'H.265 VCN 2160p 4K')).toBe('H.265 VCN 2160p 4K')
expect(presetFuer('uhd', {}, PRESET_KEINE)).toBe(DEFAULT_HB_PRESET)
})
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])