Files
rippy/rippy-windows/test/titelwahl.test.ts
T
HitonabiandClaude Opus 5 7ce06889f8
Ampel / ampel (push) Successful in 1m56s
feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Commander (01.09.2026): "Mach das gerne noch mit den Serien." und "Fuer
FILME sollte so eine auswahl auch gelten z.B. Nur hauptfeature oder
hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."

Wieder lagen die Teile fertig herum und waren nur nicht verbunden:
serienOrdner(), matcheEpisoden(), episodenUmbenennen() (struktur.ts) und
tvStaffel() (tmdb.ts) sind gebaut UND getestet — aufgerufen hat sie
niemand. Folgen landeten deshalb unter Filme/, und Jellyfin sah lauter
Einzelfilme statt einer Staffel.

Neu: jeder Titel im Auftrag traegt eine ROLLE (gemeinsam/nachrichten.ts,
EINE Definition fuer Kern und Fenster):

  hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
  extra     -> Filme/<Titel> (Jahr)/extras/...
  folge     -> Serien/<Titel>/Season NN/... S01E02.mkv

* Rippy schlaegt die Rolle vor (Serie -> Folge, Film -> Hauptfilm, Rest
  -> Extra), im Dialog ist sie je Titel aenderbar.
* Die Zuordnung Datei->Rolle ist EXAKT, nicht aus Dateinamen geraten: Je
  makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt die
  Rolle des Auftragseintrags.
* Extras liegen im Unterordner "extras" neben dem Film — die Konvention,
  die Jellyfin, Emby und Kodi als Zugaben lesen. Der Ordner entsteht nur,
  wenn es wirklich Extras gibt.
* Neuer Schnellwahl-Knopf "Hauptinhalt + Extras": alles Sehenswerte, aber
  weder Sammeltitel noch Logos/Alterskennzeichen.

Episoden-Nummern: Die Laufzeit jedes gewaehlten Titels reist im Auftrag
mit (RipAuftragTitel.dauerS), die Staffel-Laufzeiten kommen ueber einen
injizierten Rueckruf von TMDb (tvStaffel). matcheEpisoden() benennt NUR
bei EINDEUTIGER Zuordnung um. Sonst behalten die Dateien ihre Namen, und
Rippy SAGT warum — entweder "keine Staffel-Laufzeiten von TMDb" oder
"nicht eindeutig zuzuordnen". Lieber gar nicht als falsch; das war ARMs
offene Wunde #395.

Die Staffel-Nummer kommt aus dem Disc-Titel (discZusatzAbtrennen, seit
5.1.1) — sonst 1.

257 Tests gruen (vorher 254), Typpruefung sauber. Version 5.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:37:10 +02:00

201 lines
8.9 KiB
TypeScript

// 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)
})
})
describe('titelVorauswahl — die Rolle bestimmt, WOHIN abgelegt wird', () => {
it('Serie: gewählte Titel sind Folgen', () => {
const wahl = titelVorauswahl([titel(0, 52), titel(1, 50), titel(2, 3)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.rolle)).toEqual(['folge', 'folge'])
})
it('Film: der Hauptinhalt ist Hauptfilm, alles andere Extra', () => {
// Hakt der Nutzer eine Zugabe zusätzlich an, landet sie damit unter
// extras/ statt als zweiter Film daneben.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 6), titel(2, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].rolle).toBe('hauptfilm')
expect(wahl.titel[1].rolle).toBe('extra')
expect(wahl.titel[2].rolle).toBe('extra')
})
it('ohne Vorauswahl ist nichts Hauptfilm', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => t.rolle === 'extra')).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)
})
})