WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge, Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen: Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben. Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet. Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck, bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline, Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware. WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte Kompression waren seine Wahl). GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg- Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format, TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength, MakeMKV-Auswahlregel laut forum.makemkv.com t=4386. 348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche folgt im naechsten Schritt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
// 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
|
||
}
|
||
|
||
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: „1 langer +
|
||
* viele kurze = Film; 6–13 gleich lange = Serienstaffel"). Bewusst
|
||
* ZURÜCKHALTEND — 'unklar' ist die häufigste und ehrlichste Antwort:
|
||
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
|
||
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
|
||
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
|
||
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
|
||
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
|
||
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
|
||
if (lang.length === 0) return 'unklar'
|
||
const laengster = lang[0].dauerS
|
||
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
|
||
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
|
||
const zweiter = lang[1]?.dauerS ?? 0
|
||
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
|
||
return 'unklar'
|
||
}
|
||
|
||
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<number>()
|
||
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 }
|
||
}
|