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
+18 -23
View File
@@ -1,29 +1,24 @@
Version 5.2.0 Version 5.3.0
Du wählst jetzt aus, was gerippt wird. Serien landen jetzt dort, wo Jellyfin sie sucht.
Bisher hat Rippy immer ALLES von der Disc geholt — auch Trailer, Werbung Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme
und Alterskennzeichen. Ein Klick auf "Rippen" liest jetzt erst die Titel statt einer Staffel. Jetzt legt Rippy sie unter Serien/<Titel>/Season 01
der Disc (das dauert bis zu zwei Minuten, solange dreht das Laufwerk) und ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten
zeigt sie dir mit Dauer, Größe und Kapitelzahl. eindeutig zur Staffel passen; sonst behalten die Dateien ihre Namen und
Rippy sagt das. Lieber gar nicht als falsch.
Rippy macht dabei einen Vorschlag. Bei Filmen wählst du jetzt auch, was mit soll.
Bei einer Serie hakt es so viele Titel an, wie die Disc Folgen nennt — Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst
und sagt dazu, ob die Zahl passt. Bei einem Film den längsten Titel. Zwei du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen
Fallen kennt es: einen Sammeltitel, der alle Folgen am Stück enthält, im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi
wählt es NICHT vor; und wenn mehrere Titel fast gleich lang sind, sagt es sie als Zugaben und nicht als zweiten Film.
offen, dass es unsicher ist, statt sich stumm zu entscheiden.
Ein Defekt auf der Disc kostet nicht mehr alles. Neuer Knopf in der Auswahl: "Hauptinhalt + Extras".
Rippy rippt jetzt Titel für Titel. Merkt es, dass MakeMKV immer wieder an Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und
derselben Stelle hängt, sagt es das im Klartext und bietet an, diesen Alterskennzeichen.
einen Titel zu überspringen — der Rest der Disc läuft weiter. Vorher riss
ein kaputter Sektor den ganzen Durchlauf mit, und im Protokoll rauschte
stundenlang dieselbe Zeile durch.
Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung überarbeitet. Aus 5.2.0: Titel-Auswahl vor dem Rip und der Defekt-Waechter.
Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung ueberarbeitet.
Aus 5.1.4: Das Update installiert sich wirklich. Aus 5.1.4: Das Update installiert sich wirklich.
Aus 5.1.3: Eigenes Fenster, Layout wächst mit.
Aus 5.1.2: Rippy sagt Bescheid, wenn ein Update da ist.
Aus 5.1.1: Serien-Discs werden richtig erkannt.
Unverändert: Updates warten immer, bis kein Rip mehr läuft. Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rippy", "name": "rippy",
"version": "5.2.0", "version": "5.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rippy", "name": "rippy",
"version": "5.2.0", "version": "5.3.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rippy", "name": "rippy",
"productName": "Rippy", "productName": "Rippy",
"version": "5.2.0", "version": "5.3.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js", "main": "out/haupt/index.js",
"author": "KrBrZ", "author": "KrBrZ",
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: // Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; // Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. // React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
// //
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird // R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« // jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die // zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. // Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { import {
istKernNachricht, istKernNachricht,
type BeschaffungStand, type BeschaffungStand,
type BibliothekEintrag, type BibliothekEintrag,
type DiscInfoStand, type DiscInfoStand,
type GeraeteInfo, type GeraeteInfo,
type KernNachricht, type KernNachricht,
type MetaVorschlag, type MetaVorschlag,
type TitelStand, type TitelStand,
type RipStatus, type RipAuftragTitel,
type WerkzeugAuskunft, type RipStatus,
} from '../gemeinsam/nachrichten' type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean export interface Verbindung {
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null verbunden: boolean
pongLatenzMs: number | null kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
laufwerke: GeraeteInfo[] pongLatenzMs: number | null
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, laufwerke: GeraeteInfo[]
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
rips: Record<string, RipStatus> * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ rips: Record<string, RipStatus>
discInfos: Record<string, DiscInfoStand> /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ discInfos: Record<string, DiscInfoStand>
vorschlaege: Record<string, MetaVorschlag[]> /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */ vorschlaege: Record<string, MetaVorschlag[]>
discTitel: Record<string, TitelStand> /** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ discTitel: Record<string, TitelStand>
ripStart: Record<string, number> /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare ripStart: Record<string, number>
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
* Vorgang beginnt. */ * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
ripProtokoll: Record<string, string[]> * Vorgang beginnt. */
einstellungen: Record<string, string> ripProtokoll: Record<string, string[]>
werkzeuge: WerkzeugAuskunft | null einstellungen: Record<string, string>
bibliothek: BibliothekEintrag[] werkzeuge: WerkzeugAuskunft | null
schluessel: { bibliothek: BibliothekEintrag[]
quelle: string schluessel: {
angenommen: boolean | null quelle: string
weiterBetrieb: boolean angenommen: boolean | null
gueltigBis: string weiterBetrieb: boolean
geholtAm: string gueltigBis: string
text: string geholtAm: string
version: string text: string
} | null version: string
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ } | null
beschaffung: BeschaffungStand | null /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ beschaffung: BeschaffungStand | null
meldungen: string[] /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
} meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 20
const MAX_MELDUNGEN = 20
const stand: Verbindung = {
verbunden: false, const stand: Verbindung = {
kern: null, verbunden: false,
pongLatenzMs: null, kern: null,
laufwerke: [], pongLatenzMs: null,
rips: {}, laufwerke: [],
discInfos: {}, rips: {},
discTitel: {}, discInfos: {},
vorschlaege: {}, discTitel: {},
ripStart: {}, vorschlaege: {},
ripProtokoll: {}, ripStart: {},
einstellungen: {}, ripProtokoll: {},
werkzeuge: null, einstellungen: {},
bibliothek: [], werkzeuge: null,
schluessel: null, bibliothek: [],
beschaffung: null, schluessel: null,
meldungen: [], beschaffung: null,
} meldungen: [],
const horcher = new Set<Horcher>() }
let gestartet = false const horcher = new Set<Horcher>()
let port: MessagePort | null = null let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) { function melden(): void {
h({ for (const h of horcher) {
...stand, h({
laufwerke: [...stand.laufwerke], ...stand,
rips: { ...stand.rips }, laufwerke: [...stand.laufwerke],
discInfos: { ...stand.discInfos }, rips: { ...stand.rips },
vorschlaege: { ...stand.vorschlaege }, discInfos: { ...stand.discInfos },
einstellungen: { ...stand.einstellungen }, vorschlaege: { ...stand.vorschlaege },
bibliothek: [...stand.bibliothek], einstellungen: { ...stand.einstellungen },
meldungen: [...stand.meldungen], bibliothek: [...stand.bibliothek],
}) meldungen: [...stand.meldungen],
} })
} }
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) function meldung(text: string): void {
} stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) { function kernNachricht(nachricht: KernNachricht): void {
case 'kern-bereit': switch (nachricht.art) {
stand.kern = nachricht case 'kern-bereit':
break stand.kern = nachricht
case 'pong': break
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) case 'pong':
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
window.rippy.melden('kern-pong') // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
break window.rippy.melden('kern-pong')
case 'kern-fehler': break
meldung(`${nachricht.text}`) case 'kern-fehler':
break meldung(`${nachricht.text}`)
case 'laufwerke': break
stand.laufwerke = nachricht.liste case 'laufwerke':
for (const ereignis of nachricht.ereignisse) { stand.laufwerke = nachricht.liste
if (ereignis.art === 'disc-eingelegt') { for (const ereignis of nachricht.ereignisse) {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`) if (ereignis.art === 'disc-eingelegt') {
} else if (ereignis.art === 'disc-entnommen') { meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
meldung(`Disc entnommen aus ${ereignis.geraet.name}`) } else if (ereignis.art === 'disc-entnommen') {
} else if (ereignis.art === 'laufwerk-weg') { meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) } else if (ereignis.art === 'laufwerk-weg') {
} meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
} }
break }
case 'auswurf-ergebnis': { break
const text = case 'auswurf-ergebnis': {
nachricht.grund === 'ok' const text =
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` nachricht.grund === 'ok'
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}` ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
meldung(nachricht.grund === 'ok' ? text : ` ${text}`) : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` ${nachricht.fehler}` : ''}`
break meldung(nachricht.grund === 'ok' ? text : `${text}`)
} break
case 'disc-info': { }
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } case 'disc-info': {
if (nachricht.info.titel.length > 0) { stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
meldung( if (nachricht.info.titel.length > 0) {
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, meldung(
) `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
} )
break }
} break
case 'disc-titel': }
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand } case 'disc-titel':
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`) stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
break if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
case 'metadaten-vorschlaege': break
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } case 'metadaten-vorschlaege':
break stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
case 'einstellungen': break
stand.einstellungen = nachricht.werte case 'einstellungen':
break stand.einstellungen = nachricht.werte
case 'werkzeuge': break
stand.werkzeuge = nachricht.auskunft case 'werkzeuge':
break stand.werkzeuge = nachricht.auskunft
case 'bibliothek': break
stand.bibliothek = nachricht.eintraege case 'bibliothek':
break stand.bibliothek = nachricht.eintraege
case 'schluessel-status': break
stand.schluessel = nachricht.stand case 'schluessel-status':
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) stand.schluessel = nachricht.stand
break if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
case 'beschaffung-status': break
stand.beschaffung = nachricht.stand case 'beschaffung-status':
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) stand.beschaffung = nachricht.stand
break if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
case 'rip-status': { break
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } case 'rip-status': {
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
} else if (!laeuft) { stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart } else if (!laeuft) {
stand.ripStart = rest 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). // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] // wiederholen den Text — nur echte Änderungen zählen).
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
stand.ripProtokoll = { 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), ...stand.ripProtokoll,
} [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
} }
if (nachricht.status.phase === 'fertig') { }
meldung( if (nachricht.status.phase === 'fertig') {
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + meldung(
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), `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 === 'fehler') {
} else if (nachricht.status.phase === 'abgebrochen') { meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) } else if (nachricht.status.phase === 'abgebrochen') {
} meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
break }
} break
} }
melden() }
} melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort function portAnnehmen(neuerPort: MessagePort): void {
stand.verbunden = true port = neuerPort
neuerPort.onmessage = (ereignis: MessageEvent) => { stand.verbunden = true
const nachricht: unknown = ereignis.data neuerPort.onmessage = (ereignis: MessageEvent) => {
if (istKernNachricht(nachricht)) kernNachricht(nachricht) const nachricht: unknown = ereignis.data
} if (istKernNachricht(nachricht)) kernNachricht(nachricht)
neuerPort.start() }
neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) neuerPort.start()
neuerPort.postMessage({ art: 'einstellungen-laden' }) neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'werkzeuge-laden' }) neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' }) neuerPort.postMessage({ art: 'werkzeuge-laden' })
melden() 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. */ /** Idempotent — der erste Aufruf registriert den Horcher und meldet der
export function verbindungStarten(): void { * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
if (gestartet) return export function verbindungStarten(): void {
gestartet = true if (gestartet) return
window.addEventListener('message', (ereignis: MessageEvent) => { gestartet = true
const daten: unknown = ereignis.data window.addEventListener('message', (ereignis: MessageEvent) => {
if ( const daten: unknown = ereignis.data
typeof daten === 'object' && if (
daten !== null && typeof daten === 'object' &&
(daten as Record<string, unknown>).art === 'kern-port' && daten !== null &&
ereignis.ports.length > 0 (daten as Record<string, unknown>).art === 'kern-port' &&
) { ereignis.ports.length > 0
portAnnehmen(ereignis.ports[0]) ) {
} portAnnehmen(ereignis.ports[0])
}) }
window.rippy.bereit() })
} window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h) export function aufVerbindung(h: Horcher): () => void {
h({ horcher.add(h)
...stand, h({
laufwerke: [...stand.laufwerke], ...stand,
rips: { ...stand.rips }, laufwerke: [...stand.laufwerke],
discInfos: { ...stand.discInfos }, rips: { ...stand.rips },
vorschlaege: { ...stand.vorschlaege }, discInfos: { ...stand.discInfos },
einstellungen: { ...stand.einstellungen }, vorschlaege: { ...stand.vorschlaege },
bibliothek: [...stand.bibliothek], einstellungen: { ...stand.einstellungen },
meldungen: [...stand.meldungen], bibliothek: [...stand.bibliothek],
}) meldungen: [...stand.meldungen],
return () => horcher.delete(h) })
} return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void {
if (port === null) { function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
meldung(`${wasWar}: keine Verbindung zum Kern`) if (port === null) {
melden() meldung(`${wasWar}: keine Verbindung zum Kern`)
return melden()
} return
port.postMessage(nachricht) }
} port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) 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 { /** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`) 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 { /** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
anKern({ art: 'rip-start', id, titel }, `Rip ${id}`) 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 { /** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`) 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 ripAbbrechen(id: string): void {
} anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return export function metadatenSuchen(id: string, text: string): void {
port.postMessage({ art: 'metadaten-suchen', id, text }) if (port === null) return
} port.postMessage({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag }) if (port === null) return
} port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) { export function makemkvUpdate(): void {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') if (port === null) {
melden() meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
return melden()
} return
port.postMessage({ art: 'makemkv-update' }) }
} port.postMessage({ art: 'makemkv-update' })
}
export function schluesselPruefen(): void {
if (port === null) return export function schluesselPruefen(): void {
port.postMessage({ art: 'schluessel-pruefen' }) if (port === null) return
} port.postMessage({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) { export function einstellungSetzen(schluessel: string, wert: string): void {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) if (port === null) {
melden() meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
return melden()
} return
port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) }
} 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). // 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 // NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern. // Datei läuft im Fenster UND im Kern.
// //
// Wege: // Wege:
// Kern → Haupt über process.parentPort (KernNachricht) // Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht) // Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus) // Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht) // Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */ /** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string } export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ────────────────────────────────────────────── // ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown' export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown' export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo { export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */ /** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string id: string
name: string name: string
typ: DiscTyp typ: DiscTyp
pfad: string pfad: string
status: LaufwerkStatus status: LaufwerkStatus
modell: string modell: string
seriennummer: string seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */ /** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */ /** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string grund: string
} }
export type WacheEreignis = export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo } | { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo } | { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo } | { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string } | { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin' export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ───────────────────────────────────────────────── // ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase = export type RipPhase =
| 'liest-info' | 'liest-info'
| 'rippt' | 'rippt'
| 'komprimiert' | 'komprimiert'
| 'legt-ab' | 'legt-ab'
| 'fertig' | 'fertig'
| 'fehler' | 'fehler'
| 'abgebrochen' | 'abgebrochen'
export interface RipStatus { export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */ /** Laufwerks-Kennung, auf der der Rip läuft. */
id: string id: string
phase: RipPhase phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */ /** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number prozent: number
text: string text: string
dateien: string[] dateien: string[]
lesefehler: boolean lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es /** 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 * insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */ * und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number titelNr?: number
titelGesamt?: number titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer /** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */ * Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null haengt?: { offset: string; wiederholungen: number } | null
} }
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */ /** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
export interface DiscTitelVorschlag { * hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
nr: number * extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
dauerS: number * folge → Serien/<Titel>/Season NN/… S01E02.mkv
groesseBytes: number * EINE Definition für Kern und Fenster. */
kapitel: number export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Von Rippy vorausgewählt. */
gewaehlt: boolean /** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */ * Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
grund: string * gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
} * bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s, nr: number
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der rolle: TitelRolle
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */ dauerS: number
export interface TitelStand { }
id: string
laeuft: boolean /** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
titel: DiscTitelVorschlag[] export interface DiscTitelVorschlag {
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */ nr: number
einordnung: string dauerS: number
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */ groesseBytes: number
unsicher: boolean kapitel: number
fehler: string /** Von Rippy vorausgewählt. */
} gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */ rolle: TitelRolle
export interface DiscInfoStand { /** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
id: string grund: string
titel: string }
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */ /** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
confidence: number * deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
quelle: string * Zeit nicht stumm da (R2: nichts ohne Auskunft). */
posterPfad: string export interface TitelStand {
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */ id: string
backdropPfad: string laeuft: boolean
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */ titel: DiscTitelVorschlag[]
beschreibung: string /** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am einordnung: string
* Fingerabdruck wieder (§ 6.6); null wenn neu. */ /** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
schonGerippt: string | null unsicher: boolean
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */ fehler: string
discNr: number | null }
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null /** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines export interface DiscInfoStand {
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */ id: string
folgen: number titel: string
} jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
/** Ein Eintrag der Bibliothek (§ 6.6). */ confidence: number
export interface BibliothekEintrag { quelle: string
fingerprint: string posterPfad: string
titel: string /** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
jahr: number | null backdropPfad: string
typ: string /** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
groesseBytes: number beschreibung: string
ablageort: string /** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
datum: string * Fingerabdruck wieder (§ 6.6); null wenn neu. */
dauerS: number schonGerippt: string | null
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */ /** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
posterPfad: string discNr: number | null
} /** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */ /** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
export interface MetaVorschlag { * gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
typ: 'movie' | 'tv' folgen: number
tmdbId: number }
titel: string
jahr: number | null /** Ein Eintrag der Bibliothek (§ 6.6). */
posterPfad: string export interface BibliothekEintrag {
} fingerprint: string
titel: string
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */ jahr: number | null
export interface WerkzeugAuskunft { typ: string
makemkvcon: string groesseBytes: number
handbrake: string ablageort: string
flac: string datum: string
presets: string[] dauerS: number
backends: string[] /** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
} posterPfad: string
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand { /** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
/** Welches Werkzeug — bisher nur 'makemkv'. */ export interface MetaVorschlag {
werkzeug: string typ: 'movie' | 'tv'
laeuft: boolean tmdbId: number
text: string titel: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */ jahr: number | null
anteil: number | null posterPfad: string
fehler: boolean }
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
/** Nachrichten, die der Kern verschickt. */ export interface WerkzeugAuskunft {
export type KernNachricht = makemkvcon: string
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus } handbrake: string
| { art: 'pong'; zeit: number } flac: string
| { art: 'kern-fehler'; text: string } presets: string[]
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] } backends: string[]
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand } /** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
| { art: 'disc-titel'; stand: TitelStand } export interface BeschaffungStand {
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } /** Welches Werkzeug — bisher nur 'makemkv'. */
| { art: 'einstellungen'; werte: Record<string, string> } werkzeug: string
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } laeuft: boolean
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] } text: string
| { art: 'beschaffung-status'; stand: BeschaffungStand } /** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt — anteil: number | null
* starten kann ihn nur das Haupt (shell/UAC, § 9). */ fehler: boolean
| { art: 'installer-starten'; pfad: string } }
| {
art: 'schluessel-status' /** Nachrichten, die der Kern verschickt. */
stand: { export type KernNachricht =
quelle: string | { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
angenommen: boolean | null | { art: 'pong'; zeit: number }
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */ | { art: 'kern-fehler'; text: string }
weiterBetrieb: boolean | { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
gueltigBis: string | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
geholtAm: string | { art: 'rip-status'; status: RipStatus }
text: string | { art: 'disc-info'; info: DiscInfoStand }
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */ | { art: 'disc-titel'; stand: TitelStand }
version: string | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
} | { art: 'einstellungen'; werte: Record<string, string> }
} | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */ | { art: 'beschaffung-status'; stand: BeschaffungStand }
export type FensterNachricht = /** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
| { art: 'ping'; zeit: number } * starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'auswerfen'; id: string } | { art: 'installer-starten'; pfad: 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 art: 'schluessel-status'
* sehr sehr lange"). */ stand: {
| { art: 'titel-lesen'; id: string } quelle: string
/** `titel` leer heißt: alles rippen — wie vor 5.2. */ angenommen: boolean | null
| { art: 'rip-start'; id: string; titel?: number[] } /** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
| { art: 'rip-abbruch'; id: string } weiterBetrieb: boolean
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen gueltigBis: string
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */ geholtAm: string
| { art: 'rip-ueberspringen'; id: string } text: string
| { art: 'einstellung-setzen'; schluessel: string; wert: string } /** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
| { art: 'metadaten-suchen'; id: string; text: string } version: string
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag } }
| { art: 'einstellungen-laden' } }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' } /** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */ export type FensterNachricht =
| { art: 'makemkv-update' } | { art: 'ping'; zeit: number }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */ | { art: 'auswerfen'; id: string }
| { art: 'schluessel-pruefen' } /** 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
/** Nachrichten, die das Haupt an den Kern schickt. */ * sehr sehr lange"). */
export type HauptNachricht = | { art: 'titel-lesen'; id: string }
| { art: 'fenster-port' } /** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */ | { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
| { art: 'installer-ergebnis'; fehler: string | null } | { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */ * — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
export interface HauptStatus { | { art: 'rip-ueberspringen'; id: string }
version: string | { art: 'einstellung-setzen'; schluessel: string; wert: string }
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */ | { art: 'metadaten-suchen'; id: string; text: string }
leine: { ok: boolean; fehler?: string } | { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
kern: { laeuft: boolean; pid?: number; neustarts: number } | { art: 'einstellungen-laden' }
autostart: boolean | { art: 'werkzeuge-laden' }
/** Selbst-Update (§ 6.8). */ | { art: 'bibliothek-laden' }
update: UpdateStand /** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster | { art: 'makemkv-update' }
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */ /** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
fensterMaximiert: boolean | { art: 'schluessel-pruefen' }
}
/** Nachrichten, die das Haupt an den Kern schickt. */
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt export type HauptNachricht =
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene | { art: 'fenster-port' }
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal /** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */ | { art: 'installer-ergebnis'; fehler: string | null }
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */ /** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
letzteErfolgreichePruefung: string export interface HauptStatus {
text: string version: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */ /** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
neueVersion: string leine: { ok: boolean; fehler?: string }
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */ kern: { laeuft: boolean; pid?: number; neustarts: number }
neuerungen: string autostart: boolean
/** Geladen und wartet auf den Neustart. */ /** Selbst-Update (§ 6.8). */
bereit: boolean update: UpdateStand
/** Eine Prüfung oder ein Download läuft gerade. */ /** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
laeuft: boolean * gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */ fensterMaximiert: boolean
fortschritt: number }
}
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
function istObjekt(wert: unknown): wert is Record<string, unknown> { * und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
return typeof wert === 'object' && wert !== null * Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
} * arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
export function istKernNachricht(wert: unknown): wert is KernNachricht { /** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
if (!istObjekt(wert)) return false letzteErfolgreichePruefung: string
switch (wert.art) { text: string
case 'kern-bereit': /** Die gefundene neue Version — '' wenn keine bereitsteht. */
return ( neueVersion: string
typeof wert.pid === 'number' && /** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
typeof wert.nodeVersion === 'string' && neuerungen: string
istDbStatus(wert.datenbank) /** Geladen und wartet auf den Neustart. */
) bereit: boolean
case 'pong': /** Eine Prüfung oder ein Download läuft gerade. */
return typeof wert.zeit === 'number' laeuft: boolean
case 'kern-fehler': /** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
return typeof wert.text === 'string' fortschritt: number
case 'laufwerke': }
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis': function istObjekt(wert: unknown): wert is Record<string, unknown> {
return ( return typeof wert === 'object' && wert !== null
typeof wert.id === 'string' && }
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string') export function istKernNachricht(wert: unknown): wert is KernNachricht {
) if (!istObjekt(wert)) return false
case 'rip-status': switch (wert.art) {
return istRipStatus(wert.status) case 'kern-bereit':
case 'disc-info': return (
return ( typeof wert.pid === 'number' &&
istObjekt(wert.info) && typeof wert.nodeVersion === 'string' &&
typeof (wert.info as Record<string, unknown>).id === 'string' && istDbStatus(wert.datenbank)
typeof (wert.info as Record<string, unknown>).titel === 'string' && )
typeof (wert.info as Record<string, unknown>).confidence === 'number' case 'pong':
) return typeof wert.zeit === 'number'
case 'disc-titel': case 'kern-fehler':
return ( return typeof wert.text === 'string'
istObjekt(wert.stand) && case 'laufwerke':
typeof (wert.stand as Record<string, unknown>).id === 'string' && return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' && case 'auswurf-ergebnis':
Array.isArray((wert.stand as Record<string, unknown>).titel) return (
) typeof wert.id === 'string' &&
case 'einstellungen': typeof wert.grund === 'string' &&
return istObjekt(wert.werte) (wert.fehler === null || typeof wert.fehler === 'string')
case 'werkzeuge': )
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets) case 'rip-status':
case 'bibliothek': return istRipStatus(wert.status)
return Array.isArray(wert.eintraege) case 'disc-info':
case 'schluessel-status': return (
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string' istObjekt(wert.info) &&
case 'beschaffung-status': typeof (wert.info as Record<string, unknown>).id === 'string' &&
return ( typeof (wert.info as Record<string, unknown>).titel === 'string' &&
istObjekt(wert.stand) && typeof (wert.info as Record<string, unknown>).confidence === 'number'
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' && )
typeof (wert.stand as Record<string, unknown>).text === 'string' && case 'disc-titel':
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' return (
) istObjekt(wert.stand) &&
case 'installer-starten': typeof (wert.stand as Record<string, unknown>).id === 'string' &&
return typeof wert.pfad === 'string' && wert.pfad.length > 0 typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
case 'metadaten-vorschlaege': Array.isArray((wert.stand as Record<string, unknown>).titel)
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege) )
default: case 'einstellungen':
return false return istObjekt(wert.werte)
} case 'werkzeuge':
} return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag { return Array.isArray(wert.eintraege)
if (!istObjekt(wert)) return false case 'schluessel-status':
return ( return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
(wert.typ === 'movie' || wert.typ === 'tv') && case 'beschaffung-status':
typeof wert.tmdbId === 'number' && return (
typeof wert.titel === 'string' && istObjekt(wert.stand) &&
(wert.jahr === null || typeof wert.jahr === 'number') && typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof wert.posterPfad === 'string' typeof (wert.stand as Record<string, unknown>).text === 'string' &&
) typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
} )
case 'installer-starten':
export function istRipStatus(wert: unknown): wert is RipStatus { return typeof wert.pfad === 'string' && wert.pfad.length > 0
if (!istObjekt(wert)) return false case 'metadaten-vorschlaege':
return ( return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
typeof wert.id === 'string' && default:
typeof wert.phase === 'string' && return false
typeof wert.prozent === 'number' && }
typeof wert.text === 'string' && }
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean' export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
) if (!istObjekt(wert)) return false
} return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
export function istDbStatus(wert: unknown): wert is DbStatus { typeof wert.tmdbId === 'number' &&
if (!istObjekt(wert)) return false typeof wert.titel === 'string' &&
if (wert.ok === true) return typeof wert.pfad === 'string' (wert.jahr === null || typeof wert.jahr === 'number') &&
if (wert.ok === false) return typeof wert.fehler === 'string' typeof wert.posterPfad === 'string'
return false )
} }
export function istFensterNachricht(wert: unknown): wert is FensterNachricht { export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number' return (
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { typeof wert.id === 'string' &&
return typeof wert.id === 'string' && wert.id.length > 0 typeof wert.phase === 'string' &&
} typeof wert.prozent === 'number' &&
if (wert.art === 'einstellung-setzen') { typeof wert.text === 'string' &&
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string' Array.isArray(wert.dateien) &&
} typeof wert.lesefehler === 'boolean'
if (wert.art === 'metadaten-suchen') { )
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string' }
}
if (wert.art === 'zuordnung-setzen') { export function istDbStatus(wert: unknown): wert is DbStatus {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag) if (!istObjekt(wert)) return false
} if (wert.ok === true) return typeof wert.pfad === 'string'
return ( if (wert.ok === false) return typeof wert.fehler === 'string'
wert.art === 'einstellungen-laden' || return false
wert.art === 'werkzeuge-laden' || }
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' || export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
wert.art === 'schluessel-pruefen' 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
export function istHauptNachricht(wert: unknown): wert is HauptNachricht { }
if (!istObjekt(wert)) return false if (wert.art === 'einstellung-setzen') {
if (wert.art === 'fenster-port') return true return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string' }
return false 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 // Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
// die Ampel das auf Linux prüfen kann. // die Ampel das auf Linux prüfen kann.
// //
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die // Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen, // auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
// bei filmen z. B. nur das hauptfeature oder auch die extras." // bei filmen z. B. nur das hauptfeature oder auch die extras."
// //
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte // Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen. // IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
// //
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist // 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 // (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts). // (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und // Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
// zu schweigen. // zu schweigen.
import type { TitelInfo } from './parser' // Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine import type { TitelInfo } from './parser'
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
* standardmäßig für seine eigene Mindestlänge. */ export type { TitelRolle }
export const KURZ_S = 120
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
/** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine * Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
* Serie, 50 Minuten und 3 Minuten sind es nicht. */ * standardmäßig für seine eigene Mindestlänge. */
export const AEHNLICH = 0.15 export const KURZ_S = 120
/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching" /** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl * Serie, 50 Minuten und 3 Minuten sind es nicht. */
* nicht eindeutig, und Rippy sagt das. */ export const AEHNLICH = 0.15
export const FILM_GLEICHAUF = 0.05
/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching"
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein * bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
* Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde * nicht eindeutig, und Rippy sagt das. */
* eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */ export const FILM_GLEICHAUF = 0.05
export const SAMMEL_TOLERANZ = 0.1
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
export interface TitelVorschlag extends TitelInfo { * Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde
/** Von Rippy vorausgewählt? */ * eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */
gewaehlt: boolean export const SAMMEL_TOLERANZ = 0.1
/** Warum — im Klartext, das Fenster zeigt es an. */
grund: string export interface TitelVorschlag extends TitelInfo {
} /** Von Rippy vorausgewählt? */
gewaehlt: boolean
export interface TitelWahl { /** Als was Rippy diesen Titel einsortieren würde. */
titel: TitelVorschlag[] rolle: TitelRolle
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */ /** Warum — im Klartext, das Fenster zeigt es an. */
einordnung: string grund: string
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */ }
unsicher: boolean
} export interface TitelWahl {
titel: TitelVorschlag[]
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */ /** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
export interface DiscHinweis { einordnung: string
istSerie: boolean /** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */ unsicher: boolean
folgen: number }
}
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
function dauerText(s: number): string { export interface DiscHinweis {
const m = Math.round(s / 60) istSerie: boolean
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min` /** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
} folgen: number
}
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean { function dauerText(s: number): string {
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS) const m = Math.round(s / 60)
// Mit weniger als zwei Teilen gibt es nichts zu sammeln. return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
if (kuerzere.length < 2) return false }
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
if (summe <= 0) return false /** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ 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
* Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster. const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
* if (summe <= 0) return false
* Drei Fälle: return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
* * 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. * Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster.
*/ *
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl { * Drei Fälle:
if (alle.length === 0) { * * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true } * Inhaltsverzeichnis Folgen nennt.
} * * Film: der längste Titel, der kein Sammeltitel ist.
* * Unklar: nichts vorausgewählt — lieber fragen als raten.
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr)) */
const kandidaten = alle export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr)) if (alle.length === 0) {
.sort((a, b) => b.dauerS - a.dauerS) return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
}
const gewaehlt = new Set<number>()
let einordnung: string const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
let unsicher = false const kandidaten = alle
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
if (kandidaten.length === 0) { .sort((a, b) => b.dauerS - a.dauerS)
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
unsicher = true const gewaehlt = new Set<number>()
} else if (hinweis.istSerie && hinweis.folgen >= 2) { let einordnung: string
const folgen = kandidaten.slice(0, hinweis.folgen) let unsicher = false
for (const t of folgen) gewaehlt.add(t.nr)
const laengste = folgen[0].dauerS if (kandidaten.length === 0) {
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH) einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
if (folgen.length < hinweis.folgen) { unsicher = true
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.` } else if (hinweis.istSerie && hinweis.folgen >= 2) {
unsicher = true const folgen = kandidaten.slice(0, hinweis.folgen)
} else if (!passen) { for (const t of folgen) gewaehlt.add(t.nr)
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.` const laengste = folgen[0].dauerS
unsicher = true const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
} else { if (folgen.length < hinweis.folgen) {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.` einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
} unsicher = true
} else { } else if (!passen) {
const laengster = kandidaten[0] einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
const gleichauf = kandidaten.filter( unsicher = true
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF, } else {
) einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
if (gleichauf.length > 1) { }
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist } else {
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht. const laengster = kandidaten[0]
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0] const gleichauf = kandidaten.filter(
gewaehlt.add(beste.nr) (t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.` )
unsicher = true if (gleichauf.length > 1) {
} else { // Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
gewaehlt.add(laengster.nr) // erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.` 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
const titel: TitelVorschlag[] = alle.map((t) => ({ } else {
...t, gewaehlt.add(laengster.nr)
gewaehlt: gewaehlt.has(t.nr), einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
grund: gewaehlt.has(t.nr) }
? hinweis.istSerie && hinweis.folgen >= 2 }
? 'Folge'
: 'Hauptinhalt' const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
: sammel.has(t.nr) const titel: TitelVorschlag[] = alle.map((t) => ({
? 'Sammeltitel — enthält vermutlich alles am Stück' ...t,
: t.dauerS < KURZ_S gewaehlt: gewaehlt.has(t.nr),
? 'zu kurz — Logo, Warnung oder Menü' // Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
: 'Zugabe', // 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',
return { titel, einordnung, unsicher } 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 }
}
+200 -179
View File
@@ -1,179 +1,200 @@
// Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"? // Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"?
// //
// Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'` // Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'`
// — Rippy rippte IMMER alles, inklusive Trailer, Werbung und // — Rippy rippte IMMER alles, inklusive Trailer, Werbung und
// Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die // Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte." // auswahl WAS man rippen möchte."
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { offsetAusMeldung } from '../src/kern/rip/parser' import { offsetAusMeldung } from '../src/kern/rip/parser'
import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv' import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv'
import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl' import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl'
import type { TitelInfo } from '../src/kern/rip/parser' import type { TitelInfo } from '../src/kern/rip/parser'
function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo { function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo {
return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel } return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel }
} }
const KEIN_HINWEIS = { istSerie: false, folgen: 0 } const KEIN_HINWEIS = { istSerie: false, folgen: 0 }
describe('titelVorauswahl — Serie', () => { describe('titelVorauswahl — Serie', () => {
it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => { it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => {
// Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und // Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und
// Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2. // Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2.
const wahl = titelVorauswahl( const wahl = titelVorauswahl(
[titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)], [titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)],
{ istSerie: true, folgen: 2 }, { istSerie: true, folgen: 2 },
) )
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(false) expect(wahl.unsicher).toBe(false)
expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden') expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden')
expect(wahl.titel[0].grund).toBe('Folge') expect(wahl.titel[0].grund).toBe('Folge')
expect(wahl.titel[3].grund).toContain('zu kurz') expect(wahl.titel[3].grund).toContain('zu kurz')
}) })
it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => { it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => {
const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 }) const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 })
expect(wahl.unsicher).toBe(true) expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('nur 1 gefunden') expect(wahl.einordnung).toContain('nur 1 gefunden')
}) })
it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => { it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => {
// 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber // 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber
// Rippy behauptet nicht, sie sei sicher. // Rippy behauptet nicht, sie sei sicher.
const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 }) const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(true) expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('unterschiedlich lang') expect(wahl.einordnung).toContain('unterschiedlich lang')
}) })
it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => { it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => {
// 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH. // 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH.
expect((50 - 45) / 50).toBeLessThan(AEHNLICH) expect((50 - 45) / 50).toBeLessThan(AEHNLICH)
const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 }) const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 })
expect(wahl.unsicher).toBe(false) expect(wahl.unsicher).toBe(false)
}) })
}) })
describe('titelVorauswahl — Film', () => { describe('titelVorauswahl — Film', () => {
it('der längste Titel ist der Hauptinhalt', () => { it('der längste Titel ist der Hauptinhalt', () => {
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS) const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0])
expect(wahl.unsicher).toBe(false) expect(wahl.unsicher).toBe(false)
expect(wahl.titel[0].grund).toBe('Hauptinhalt') expect(wahl.titel[0].grund).toBe('Hauptinhalt')
expect(wahl.titel[1].grund).toBe('Zugabe') expect(wahl.titel[1].grund).toBe('Zugabe')
}) })
it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => { it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => {
// Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten. // Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten.
// Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist. // Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
const wahl = titelVorauswahl( const wahl = titelVorauswahl(
[titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)], [titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)],
KEIN_HINWEIS, KEIN_HINWEIS,
) )
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
expect(wahl.unsicher).toBe(true) expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('fast gleich lang') expect(wahl.einordnung).toContain('fast gleich lang')
}) })
}) })
describe('titelVorauswahl — die Sammeltitel-Falle', () => { describe('titelVorauswahl — die Sammeltitel-Falle', () => {
it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => { it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => {
// 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der // 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der
// längste — und Rippy würde alles doppelt sichern. // längste — und Rippy würde alles doppelt sichern.
const wahl = titelVorauswahl( const wahl = titelVorauswahl(
[titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)], [titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)],
KEIN_HINWEIS, KEIN_HINWEIS,
) )
expect(wahl.titel[0].gewaehlt).toBe(false) expect(wahl.titel[0].gewaehlt).toBe(false)
expect(wahl.titel[0].grund).toContain('Sammeltitel') expect(wahl.titel[0].grund).toContain('Sammeltitel')
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
}) })
it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => { it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => {
const wahl = titelVorauswahl( const wahl = titelVorauswahl(
[titel(0, 100), titel(1, 50), titel(2, 50)], [titel(0, 100), titel(1, 50), titel(2, 50)],
{ istSerie: true, folgen: 2 }, { istSerie: true, folgen: 2 },
) )
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2]) expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2])
}) })
it('ein einzelner langer Titel ist KEIN Sammeltitel', () => { it('ein einzelner langer Titel ist KEIN Sammeltitel', () => {
// Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe // Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe
// von irgendetwas, sonst würde jeder Film als Sammeltitel gelten. // von irgendetwas, sonst würde jeder Film als Sammeltitel gelten.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS) const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].gewaehlt).toBe(true) expect(wahl.titel[0].gewaehlt).toBe(true)
}) })
}) })
describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => { describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => {
it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => { it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS) const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true) expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true)
expect(wahl.unsicher).toBe(true) expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`) expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`)
}) })
it('gar keine Titel', () => { it('gar keine Titel', () => {
const wahl = titelVorauswahl([], KEIN_HINWEIS) const wahl = titelVorauswahl([], KEIN_HINWEIS)
expect(wahl.titel).toEqual([]) expect(wahl.titel).toEqual([])
expect(wahl.unsicher).toBe(true) expect(wahl.unsicher).toBe(true)
}) })
}) })
// ── Der Defekt-Wächter ────────────────────────────────────────────────── describe('titelVorauswahl — die Rolle bestimmt, WOHIN abgelegt wird', () => {
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe it('Serie: gewählte Titel sind Folgen', () => {
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU const wahl = titelVorauswahl([titel(0, 52), titel(1, 50), titel(2, 3)], { istSerie: true, folgen: 2 })
// (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.rolle)).toEqual(['folge', 'folge'])
// 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt. })
const FEHLER_DE =
"Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'" it('Film: der Hauptinhalt ist Hauptfilm, alles andere Extra', () => {
const FEHLER_EN = // Hakt der Nutzer eine Zugabe zusätzlich an, landet sie damit unter
"Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'" // extras/ statt als zweiter Film daneben.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 6), titel(2, 4)], KEIN_HINWEIS)
describe('offsetAusMeldung', () => { expect(wahl.titel[0].rolle).toBe('hauptfilm')
it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => { expect(wahl.titel[1].rolle).toBe('extra')
expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152') expect(wahl.titel[2].rolle).toBe('extra')
expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152') })
})
it('ohne Vorauswahl ist nichts Hauptfilm', () => {
it('ohne Stelle bleibt es leer', () => { const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('') expect(wahl.titel.every((t) => t.rolle === 'extra')).toBe(true)
expect(offsetAusMeldung('')).toBe('') })
// Eine einzelne Ziffer ist eine Titelnummer, kein Offset. })
expect(offsetAusMeldung("Titel '3'")).toBe('')
}) // ── Der Defekt-Wächter ──────────────────────────────────────────────────
}) // Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => { // (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in
const zeile = (text: string): string => `MSG:2003,0,3,"${text}"` // 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt.
const FEHLER_DE =
it('dieselbe Stelle immer wieder = Defekt', () => { "Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'"
const a = new RipAuswertung() const FEHLER_EN =
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE)) "Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'"
expect(a.haengtBei).toBe('56881152')
expect(a.haengt()).toBe(true) describe('offsetAusMeldung', () => {
}) it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => {
expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152')
it('ein kurzer Aussetzer ist noch kein Defekt', () => { expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152')
const a = new RipAuswertung() })
for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(false) it('ohne Stelle bleibt es leer', () => {
}) expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('')
expect(offsetAusMeldung('')).toBe('')
it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => { // Eine einzelne Ziffer ist eine Titelnummer, kein Offset.
const a = new RipAuswertung() expect(offsetAusMeldung("Titel '3'")).toBe('')
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE)) })
expect(a.haengt()).toBe(true) })
a.verarbeite('PRGV:16384,16384,65536')
expect(a.haengt()).toBe(false) describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => {
expect(a.haengtBei).toBe('') const zeile = (text: string): string => `MSG:2003,0,3,"${text}"`
})
it('dieselbe Stelle immer wieder = Defekt', () => {
it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => { const a = new RipAuswertung()
const a = new RipAuswertung() for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
for (let i = 0; i < HAENGT_AB * 2; i++) { expect(a.haengtBei).toBe('56881152')
a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048)))) expect(a.haengt()).toBe(true)
} })
expect(a.haengt()).toBe(false)
}) it('ein kurzer Aussetzer ist noch kein Defekt', () => {
}) const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(false)
})
it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(true)
a.verarbeite('PRGV:16384,16384,65536')
expect(a.haengt()).toBe(false)
expect(a.haengtBei).toBe('')
})
it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB * 2; i++) {
a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048))))
}
expect(a.haengt()).toBe(false)
})
})