feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
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>
This commit is contained in:
Hitonabi
2026-09-01 19:37:10 +02:00
co-authored by Claude Opus 5
parent 043052b453
commit 7ce06889f8
10 changed files with 4493 additions and 4306 deletions
+158 -147
View File
@@ -1,147 +1,158 @@
// 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.
import type { TitelInfo } from './parser'
/** 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
/** 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<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 titel: TitelVorschlag[] = alle.map((t) => ({
...t,
gewaehlt: gewaehlt.has(t.nr),
grund: gewaehlt.has(t.nr)
? hinweis.istSerie && hinweis.folgen >= 2
? '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 }
}
// 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<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 }
}