// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit // die Ampel das auf Linux prüfen kann. // // Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die // auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen, // bei filmen z. B. nur das hauptfeature oder auch die extras." // // Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte // IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen. // // Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist // (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt // (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts). // Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und // zu schweigen. // Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/). import type { TitelRolle } from '../../gemeinsam/nachrichten' import type { TitelInfo } from './parser' export type { TitelRolle } /** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine * Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon * standardmäßig für seine eigene Mindestlänge. */ export const KURZ_S = 120 /** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine * Serie, 50 Minuten und 3 Minuten sind es nicht. */ export const AEHNLICH = 0.15 /** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching" * bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl * nicht eindeutig, und Rippy sagt das. */ export const FILM_GLEICHAUF = 0.05 /** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein * Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde * eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */ export const SAMMEL_TOLERANZ = 0.1 export interface TitelVorschlag extends TitelInfo { /** Von Rippy vorausgewählt? */ gewaehlt: boolean /** Als was Rippy diesen Titel einsortieren würde. */ rolle: TitelRolle /** Warum — im Klartext, das Fenster zeigt es an. */ grund: string } export interface TitelWahl { titel: TitelVorschlag[] /** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */ einordnung: string /** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */ unsicher: boolean } /** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */ export interface DiscHinweis { istSerie: boolean /** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */ folgen: number } function dauerText(s: number): string { const m = Math.round(s / 60) return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min` } /** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */ function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean { const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS) // Mit weniger als zwei Teilen gibt es nichts zu sammeln. if (kuerzere.length < 2) return false const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0) if (summe <= 0) return false return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ } /** * Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster. * * Drei Fälle: * * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das * Inhaltsverzeichnis Folgen nennt. * * Film: der längste Titel, der kein Sammeltitel ist. * * Unklar: nichts vorausgewählt — lieber fragen als raten. */ export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl { if (alle.length === 0) { return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true } } const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr)) const kandidaten = alle .filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr)) .sort((a, b) => b.dauerS - a.dauerS) const gewaehlt = new Set() let einordnung: string let unsicher = false if (kandidaten.length === 0) { einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.` unsicher = true } else if (hinweis.istSerie && hinweis.folgen >= 2) { const folgen = kandidaten.slice(0, hinweis.folgen) for (const t of folgen) gewaehlt.add(t.nr) const laengste = folgen[0].dauerS const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH) if (folgen.length < hinweis.folgen) { einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.` unsicher = true } else if (!passen) { einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.` unsicher = true } else { einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.` } } else { const laengster = kandidaten[0] const gleichauf = kandidaten.filter( (t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF, ) if (gleichauf.length > 1) { // Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist // erfahrungsgemäß die Hauptfassung — sicher ist das nicht. const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0] gewaehlt.add(beste.nr) einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.` unsicher = true } else { gewaehlt.add(laengster.nr) einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.` } } const alsSerie = hinweis.istSerie && hinweis.folgen >= 2 const titel: TitelVorschlag[] = alle.map((t) => ({ ...t, gewaehlt: gewaehlt.has(t.nr), // Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm. // Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als // Extra abgelegt, nicht als zweiten Hauptfilm. rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra', grund: gewaehlt.has(t.nr) ? alsSerie ? 'Folge' : 'Hauptinhalt' : sammel.has(t.nr) ? 'Sammeltitel — enthält vermutlich alles am Stück' : t.dauerS < KURZ_S ? 'zu kurz — Logo, Warnung oder Menü' : 'Zugabe', })) return { titel, einordnung, unsicher } }