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:
co-authored by
Claude Opus 5
parent
783b8e4897
commit
c1ee339446
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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'] ?? '')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 …".')
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) : '',
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user