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
File diff suppressed because it is too large Load Diff
+320 -319
View File
@@ -1,319 +1,320 @@
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type TitelStand,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
* Vorgang beginnt. */
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
version: string
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20
const stand: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
schluessel: null,
beschaffung: null,
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
}
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
break
case 'pong':
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : ` ${text}`)
break
}
case 'disc-info': {
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
break
}
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'beschaffung-status':
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
// wiederholen den Text — nur echte Änderungen zählen).
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
stand.ripProtokoll = {
...stand.ripProtokoll,
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
}
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
)
} else if (nachricht.status.phase === 'fehler') {
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
} else if (nachricht.status.phase === 'abgebrochen') {
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
}
break
}
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
melden()
}
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
export function verbindungStarten(): void {
if (gestartet) return
gestartet = true
window.addEventListener('message', (ereignis: MessageEvent) => {
const daten: unknown = ereignis.data
if (
typeof daten === 'object' &&
daten !== null &&
(daten as Record<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
/** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `titel` leer heißt: alles rippen. */
export function ripStarten(id: string, titel: number[] = []): void {
anKern({ art: 'rip-start', id, titel }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
export function ripUeberspringen(id: string): void {
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
}
export function schluesselPruefen(): void {
if (port === null) return
port.postMessage({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
}
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type TitelStand,
type RipAuftragTitel,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
* Vorgang beginnt. */
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
version: string
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20
const stand: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
schluessel: null,
beschaffung: null,
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
}
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
break
case 'pong':
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` ${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'disc-info': {
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
break
}
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'beschaffung-status':
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
// wiederholen den Text — nur echte Änderungen zählen).
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
stand.ripProtokoll = {
...stand.ripProtokoll,
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
}
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
)
} else if (nachricht.status.phase === 'fehler') {
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
} else if (nachricht.status.phase === 'abgebrochen') {
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
}
break
}
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
melden()
}
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
export function verbindungStarten(): void {
if (gestartet) return
gestartet = true
window.addEventListener('message', (ereignis: MessageEvent) => {
const daten: unknown = ereignis.data
if (
typeof daten === 'object' &&
daten !== null &&
(daten as Record<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
/** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void {
anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
export function ripUeberspringen(id: string): void {
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
}
export function schluesselPruefen(): void {
if (port === null) return
port.postMessage({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
}
+405 -386
View File
@@ -1,386 +1,405 @@
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
export interface TitelStand {
id: string
laeuft: boolean
titel: DiscTitelVorschlag[]
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
einordnung: string
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
fehler: string
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
beschreibung: string
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
posterPfad: string
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand {
/** Welches Werkzeug — bisher nur 'makemkv'. */
werkzeug: string
laeuft: boolean
text: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'installer-starten'; pfad: string }
| {
art: 'schluessel-status'
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
version: string
}
}
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
/** Titel-Lauf anstoßen (§ 6.2): dauert 20120 s, deshalb erst auf Zuruf
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** `titel` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; titel?: number[] }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
| { art: 'fenster-port' }
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
| { art: 'installer-ergebnis'; fehler: string | null }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean
/** Selbst-Update (§ 6.8). */
update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
}
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
neueVersion: string
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
neuerungen: string
/** Geladen und wartet auf den Neustart. */
bereit: boolean
/** Eine Prüfung oder ein Download läuft gerade. */
laeuft: boolean
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
fortschritt: number
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
case 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
)
case 'installer-starten':
return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
typeof wert.id === 'string' &&
typeof wert.phase === 'string' &&
typeof wert.prozent === 'number' &&
typeof wert.text === 'string' &&
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean'
)
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
return typeof wert.id === 'string' && wert.id.length > 0
}
if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen'
)
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'fenster-port') return true
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
return false
}
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
}
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
* EINE Definition für Kern und Fenster. */
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
* bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
nr: number
rolle: TitelRolle
dauerS: number
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
rolle: TitelRolle
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
export interface TitelStand {
id: string
laeuft: boolean
titel: DiscTitelVorschlag[]
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
einordnung: string
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
fehler: string
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
beschreibung: string
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
posterPfad: string
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand {
/** Welches Werkzeug — bisher nur 'makemkv'. */
werkzeug: string
laeuft: boolean
text: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
}
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'installer-starten'; pfad: string }
| {
art: 'schluessel-status'
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
version: string
}
}
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
/** Titel-Lauf anstoßen (§ 6.2): dauert 20120 s, deshalb erst auf Zuruf
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
| { art: 'fenster-port' }
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
| { art: 'installer-ergebnis'; fehler: string | null }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean
/** Selbst-Update (§ 6.8). */
update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
}
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
neueVersion: string
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
neuerungen: string
/** Geladen und wartet auf den Neustart. */
bereit: boolean
/** Eine Prüfung oder ein Download läuft gerade. */
laeuft: boolean
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
fortschritt: number
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
case 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
)
case 'installer-starten':
return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
typeof wert.id === 'string' &&
typeof wert.phase === 'string' &&
typeof wert.prozent === 'number' &&
typeof wert.text === 'string' &&
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean'
)
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
return typeof wert.id === 'string' && wert.id.length > 0
}
if (wert.art === 'einstellung-setzen') {
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen'
)
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'fenster-port') return true
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
return false
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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 }
}