Ampel / ampel (push) Successful in 1m56s
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>
201 lines
8.9 KiB
TypeScript
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)
|
|
})
|
|
})
|