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>
159 lines
6.7 KiB
TypeScript
159 lines
6.7 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
|
|
}
|
|
|
|
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 }
|
|
}
|