feat(v5): Titel auswaehlen statt immer alles — und ein Defekt kostet nicht mehr alles
Ampel / ampel (push) Successful in 1m34s
Ampel / ampel (push) Successful in 1m34s
Commander (01.09.2026): "es fehlt generell noch komplett die auswahl WAS
man rippen moechte. Bei Serien eben genau das die folgen, bei filmen z.B.
nur das hauptfeature oder auch die extras."
Bis hierher uebergab die Pipeline fest `titel: 'all'` — Rippy rippte
IMMER alles, samt Trailer, Werbung und Alterskennzeichen. Beide Wuensche
brauchen denselben Umbau, deshalb in einem Zug:
TITEL FUER TITEL statt einem 'all'-Aufruf. Nur so laesst sich auswaehlen,
und nur so ueberlebt der Rest, wenn EIN Titel an einem Disc-Defekt haengt.
Was schon da war und nur nicht verbunden: titelInfoLesen() (Titel mit
Dauer/Groesse/Kapiteln) rief niemand auf, und buildRipArgs() konnte
laengst einen einzelnen Titel. Dazu kennt Rippy seit heute das
Inhaltsverzeichnis der Disc (EP 1, EP 2, DUB GER 1, 104 GER FSK).
Neu kern/rip/titelwahl.ts (pur, ohne Laufwerk):
* Serie (von der Disc BEZEUGT): so viele Titel wie das Inhaltsverzeichnis
Folgen nennt; passen sie nicht zusammen, sagt Rippy das.
* Film: der laengste Titel.
* Zwei Fallen mit Tests abgesichert:
- SAMMELTITEL: Ein Titel, der so lang ist wie die anderen zusammen
("alle Folgen am Stueck"), waere der laengste und wuerde die
Film-Auswahl gewinnen — Rippy wuerde alles doppelt sichern.
- ANGEL/Seamless Branching: Mehrere fast gleich lange Fassungen. Rippy
nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
* Unklar heisst: nichts vorausgewaehlt. Lieber fragen als raten.
Der Defekt-Waechter:
* offsetAusMeldung() liest die Stelle aus MakeMKVs Meldung — als LETZTE
gequotete Zahl, damit es unabhaengig von MakeMKVs Sprache bleibt
(deutsch "bei Offset", englisch "at offset").
* RipAuswertung zaehlt Wiederholungen derselben Stelle; ab HAENGT_AB gilt
der Lauf als haengend. Fortschritt raeumt den Verdacht wieder ab.
* Das Fenster zeigt dann Klartext plus "Titel ueberspringen" — der
laufende makemkvcon wird beendet, der naechste Titel laeuft weiter.
Gemessen am lebenden Objekt (Spartacus Disc 2, 01.09.2026): MakeMKV
meldete eine halbe Stunde denselben Offset 56881152, makemkvcon
verbrauchte dabei 1,7 s CPU (es wartete), und Windows protokollierte 41
fehlerhafte Bloecke in 10 Minuten. Der Defekt lag in Folge 1 — Folge 2
lag heil daneben und ging trotzdem verloren.
17 neue Tests in test/titelwahl.test.ts, alle beim ersten Lauf gruen.
254 Tests gesamt (vorher 237), Typpruefung sauber. Version 5.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1253148652
commit
a8e8b2bef3
@@ -27,7 +27,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
abbruch: AbortController
|
||||
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||
titelAbbruch?: AbortController
|
||||
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||
uebersprungen?: boolean
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
@@ -86,8 +91,19 @@ export class Pipeline {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||
* während Folge 2 heil daneben lag). */
|
||||
ueberspringen(id: string): void {
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||
vorgang.uebersprungen = true
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */
|
||||
async starten(id: string, titelWahl: readonly number[] = []): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
@@ -143,48 +159,134 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const auftrag = titelWahl.length > 0 ? titelWahl.map(String) : ['all']
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
||||
})
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
return
|
||||
for (let i = 0; i < auftrag.length; i++) {
|
||||
const welcher = auftrag[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||
const titelAbbruch = new AbortController()
|
||||
const weiterreichen = (): void => titelAbbruch.abort()
|
||||
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang !== undefined) {
|
||||
vorgang.titelAbbruch = titelAbbruch
|
||||
vorgang.uebersprungen = false
|
||||
}
|
||||
|
||||
const beschriftung =
|
||||
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${auftrag.length})`
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
titelGesamt: auftrag.length,
|
||||
haengt: null,
|
||||
})
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
})
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen.',
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
uebersprungen.push(beschriftung)
|
||||
continue
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||
for (const d of rip.dateien) alleDateien.add(d)
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||
}
|
||||
}
|
||||
if (rip.status === 'error') {
|
||||
|
||||
const nachLauf = this.laufend.get(id)
|
||||
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||
|
||||
if (alleDateien.size === 0) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.',
|
||||
dateien: [],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt }
|
||||
|
||||
const lesefehlerHinweis =
|
||||
(rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '') +
|
||||
(uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') +
|
||||
(gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '')
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
|
||||
Reference in New Issue
Block a user