feat(v5): Titel auswaehlen statt immer alles — und ein Defekt kostet nicht mehr alles
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:
Hitonabi
2026-09-01 19:22:55 +02:00
co-authored by Claude Opus 5
parent 1253148652
commit a8e8b2bef3
13 changed files with 1162 additions and 363 deletions
+179
View File
@@ -0,0 +1,179 @@
// Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"?
//
// Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'`
// — Rippy rippte IMMER alles, inklusive Trailer, Werbung und
// Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte."
import { describe, expect, it } from 'vitest'
import { offsetAusMeldung } from '../src/kern/rip/parser'
import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv'
import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl'
import type { TitelInfo } from '../src/kern/rip/parser'
function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo {
return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel }
}
const KEIN_HINWEIS = { istSerie: false, folgen: 0 }
describe('titelVorauswahl — Serie', () => {
it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => {
// Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und
// Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2.
const wahl = titelVorauswahl(
[titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)],
{ istSerie: true, folgen: 2 },
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(false)
expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden')
expect(wahl.titel[0].grund).toBe('Folge')
expect(wahl.titel[3].grund).toContain('zu kurz')
})
it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => {
const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 })
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('nur 1 gefunden')
})
it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => {
// 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber
// Rippy behauptet nicht, sie sei sicher.
const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('unterschiedlich lang')
})
it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => {
// 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH.
expect((50 - 45) / 50).toBeLessThan(AEHNLICH)
const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 })
expect(wahl.unsicher).toBe(false)
})
})
describe('titelVorauswahl — Film', () => {
it('der längste Titel ist der Hauptinhalt', () => {
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0])
expect(wahl.unsicher).toBe(false)
expect(wahl.titel[0].grund).toBe('Hauptinhalt')
expect(wahl.titel[1].grund).toBe('Zugabe')
})
it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => {
// Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten.
// Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
const wahl = titelVorauswahl(
[titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)],
KEIN_HINWEIS,
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('fast gleich lang')
})
})
describe('titelVorauswahl — die Sammeltitel-Falle', () => {
it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => {
// 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der
// längste — und Rippy würde alles doppelt sichern.
const wahl = titelVorauswahl(
[titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)],
KEIN_HINWEIS,
)
expect(wahl.titel[0].gewaehlt).toBe(false)
expect(wahl.titel[0].grund).toContain('Sammeltitel')
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
})
it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => {
const wahl = titelVorauswahl(
[titel(0, 100), titel(1, 50), titel(2, 50)],
{ istSerie: true, folgen: 2 },
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2])
})
it('ein einzelner langer Titel ist KEIN Sammeltitel', () => {
// Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe
// von irgendetwas, sonst würde jeder Film als Sammeltitel gelten.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].gewaehlt).toBe(true)
})
})
describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => {
it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true)
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`)
})
it('gar keine Titel', () => {
const wahl = titelVorauswahl([], KEIN_HINWEIS)
expect(wahl.titel).toEqual([])
expect(wahl.unsicher).toBe(true)
})
})
// ── Der Defekt-Wächter ──────────────────────────────────────────────────
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
// (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in
// 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt.
const FEHLER_DE =
"Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'"
const FEHLER_EN =
"Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'"
describe('offsetAusMeldung', () => {
it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => {
expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152')
expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152')
})
it('ohne Stelle bleibt es leer', () => {
expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('')
expect(offsetAusMeldung('')).toBe('')
// Eine einzelne Ziffer ist eine Titelnummer, kein Offset.
expect(offsetAusMeldung("Titel '3'")).toBe('')
})
})
describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => {
const zeile = (text: string): string => `MSG:2003,0,3,"${text}"`
it('dieselbe Stelle immer wieder = Defekt', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengtBei).toBe('56881152')
expect(a.haengt()).toBe(true)
})
it('ein kurzer Aussetzer ist noch kein Defekt', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(false)
})
it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(true)
a.verarbeite('PRGV:16384,16384,65536')
expect(a.haengt()).toBe(false)
expect(a.haengtBei).toBe('')
})
it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB * 2; i++) {
a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048))))
}
expect(a.haengt()).toBe(false)
})
})