feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Ampel / ampel (push) Successful in 1m56s
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:
co-authored by
Claude Opus 5
parent
043052b453
commit
7ce06889f8
@@ -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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
+1974
-1922
File diff suppressed because it is too large
Load Diff
@@ -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 (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
|
||||||
export function titelLesen(id: string): void {
|
/** Den Info-Lauf anstoßen (20–120 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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
/** 0–100; -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 20–120 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
|
|
||||||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 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
|
||||||
|
/** 0–1; 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
|
||||||
/** 0–1 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 }
|
/** 0–1 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 20–120 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 20–120 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
+715
-700
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user