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>
406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
||
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
||
// Datei läuft im Fenster UND im Kern.
|
||
//
|
||
// Wege:
|
||
// Kern → Haupt über process.parentPort (KernNachricht)
|
||
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
||
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
||
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
||
|
||
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||
|
||
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||
|
||
export interface GeraeteInfo {
|
||
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||
id: string
|
||
name: string
|
||
typ: DiscTyp
|
||
pfad: string
|
||
status: LaufwerkStatus
|
||
modell: string
|
||
seriennummer: string
|
||
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||
groesseBytes: number
|
||
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||
grund: string
|
||
}
|
||
|
||
export type WacheEreignis =
|
||
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||
| { art: 'laufwerk-weg'; id: string }
|
||
|
||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||
|
||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||
export type RipPhase =
|
||
| 'liest-info'
|
||
| 'rippt'
|
||
| 'komprimiert'
|
||
| 'legt-ab'
|
||
| 'fertig'
|
||
| 'fehler'
|
||
| 'abgebrochen'
|
||
|
||
export interface RipStatus {
|
||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||
id: string
|
||
phase: RipPhase
|
||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||
prozent: number
|
||
text: string
|
||
dateien: string[]
|
||
lesefehler: boolean
|
||
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
|
||
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
|
||
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||
titelNr?: number
|
||
titelGesamt?: number
|
||
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||
haengt?: { offset: string; wiederholungen: number } | null
|
||
}
|
||
|
||
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||
* EINE Definition für Kern und Fenster. */
|
||
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||
|
||
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||
export interface RipAuftragTitel {
|
||
nr: number
|
||
rolle: TitelRolle
|
||
dauerS: number
|
||
}
|
||
|
||
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||
export interface DiscTitelVorschlag {
|
||
nr: number
|
||
dauerS: number
|
||
groesseBytes: number
|
||
kapitel: number
|
||
/** Von Rippy vorausgewählt. */
|
||
gewaehlt: boolean
|
||
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||
rolle: TitelRolle
|
||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||
grund: string
|
||
}
|
||
|
||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||
export interface TitelStand {
|
||
id: string
|
||
laeuft: boolean
|
||
titel: DiscTitelVorschlag[]
|
||
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||
einordnung: string
|
||
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||
unsicher: boolean
|
||
fehler: string
|
||
}
|
||
|
||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||
export interface DiscInfoStand {
|
||
id: string
|
||
titel: string
|
||
jahr: number | null
|
||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||
confidence: number
|
||
quelle: string
|
||
posterPfad: string
|
||
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||
backdropPfad: string
|
||
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||
beschreibung: string
|
||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||
schonGerippt: string | null
|
||
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||
discNr: number | null
|
||
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||
staffel: number | null
|
||
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||
folgen: number
|
||
}
|
||
|
||
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||
export interface BibliothekEintrag {
|
||
fingerprint: string
|
||
titel: string
|
||
jahr: number | null
|
||
typ: string
|
||
groesseBytes: number
|
||
ablageort: string
|
||
datum: string
|
||
dauerS: number
|
||
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||
posterPfad: string
|
||
}
|
||
|
||
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||
export interface MetaVorschlag {
|
||
typ: 'movie' | 'tv'
|
||
tmdbId: number
|
||
titel: string
|
||
jahr: number | null
|
||
posterPfad: string
|
||
}
|
||
|
||
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
||
export interface WerkzeugAuskunft {
|
||
makemkvcon: string
|
||
handbrake: string
|
||
flac: string
|
||
presets: string[]
|
||
backends: string[]
|
||
}
|
||
|
||
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||
export interface BeschaffungStand {
|
||
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||
werkzeug: string
|
||
laeuft: boolean
|
||
text: string
|
||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||
anteil: number | null
|
||
fehler: boolean
|
||
}
|
||
|
||
/** Nachrichten, die der Kern verschickt. */
|
||
export type KernNachricht =
|
||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||
| { art: 'pong'; zeit: number }
|
||
| { art: 'kern-fehler'; text: string }
|
||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||
| { art: 'rip-status'; status: RipStatus }
|
||
| { art: 'disc-info'; info: DiscInfoStand }
|
||
| { art: 'disc-titel'; stand: TitelStand }
|
||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||
| { art: 'installer-starten'; pfad: string }
|
||
| {
|
||
art: 'schluessel-status'
|
||
stand: {
|
||
quelle: string
|
||
angenommen: boolean | null
|
||
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
||
weiterBetrieb: boolean
|
||
gueltigBis: string
|
||
geholtAm: string
|
||
text: string
|
||
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||
version: string
|
||
}
|
||
}
|
||
|
||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||
export type FensterNachricht =
|
||
| { art: 'ping'; zeit: number }
|
||
| { art: 'auswerfen'; id: string }
|
||
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||
* sehr sehr lange"). */
|
||
| { art: 'titel-lesen'; id: string }
|
||
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
|
||
| { art: 'rip-abbruch'; id: string }
|
||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||
| { art: 'rip-ueberspringen'; id: string }
|
||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||
| { art: 'metadaten-suchen'; id: string; text: string }
|
||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||
| { art: 'einstellungen-laden' }
|
||
| { art: 'werkzeuge-laden' }
|
||
| { art: 'bibliothek-laden' }
|
||
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||
| { art: 'makemkv-update' }
|
||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||
| { art: 'schluessel-pruefen' }
|
||
|
||
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||
export type HauptNachricht =
|
||
| { art: 'fenster-port' }
|
||
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||
| { art: 'installer-ergebnis'; fehler: string | null }
|
||
|
||
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||
export interface HauptStatus {
|
||
version: string
|
||
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
||
leine: { ok: boolean; fehler?: string }
|
||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||
autostart: boolean
|
||
/** Selbst-Update (§ 6.8). */
|
||
update: UpdateStand
|
||
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
|
||
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
|
||
fensterMaximiert: boolean
|
||
}
|
||
|
||
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||
export interface UpdateStand {
|
||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||
letzteErfolgreichePruefung: string
|
||
text: string
|
||
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||
neueVersion: string
|
||
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||
neuerungen: string
|
||
/** Geladen und wartet auf den Neustart. */
|
||
bereit: boolean
|
||
/** Eine Prüfung oder ein Download läuft gerade. */
|
||
laeuft: boolean
|
||
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||
fortschritt: number
|
||
}
|
||
|
||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||
return typeof wert === 'object' && wert !== null
|
||
}
|
||
|
||
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||
if (!istObjekt(wert)) return false
|
||
switch (wert.art) {
|
||
case 'kern-bereit':
|
||
return (
|
||
typeof wert.pid === 'number' &&
|
||
typeof wert.nodeVersion === 'string' &&
|
||
istDbStatus(wert.datenbank)
|
||
)
|
||
case 'pong':
|
||
return typeof wert.zeit === 'number'
|
||
case 'kern-fehler':
|
||
return typeof wert.text === 'string'
|
||
case 'laufwerke':
|
||
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||
case 'auswurf-ergebnis':
|
||
return (
|
||
typeof wert.id === 'string' &&
|
||
typeof wert.grund === 'string' &&
|
||
(wert.fehler === null || typeof wert.fehler === 'string')
|
||
)
|
||
case 'rip-status':
|
||
return istRipStatus(wert.status)
|
||
case 'disc-info':
|
||
return (
|
||
istObjekt(wert.info) &&
|
||
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||
)
|
||
case 'disc-titel':
|
||
return (
|
||
istObjekt(wert.stand) &&
|
||
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
|
||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
|
||
Array.isArray((wert.stand as Record<string, unknown>).titel)
|
||
)
|
||
case 'einstellungen':
|
||
return istObjekt(wert.werte)
|
||
case 'werkzeuge':
|
||
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||
case 'bibliothek':
|
||
return Array.isArray(wert.eintraege)
|
||
case 'schluessel-status':
|
||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||
case 'beschaffung-status':
|
||
return (
|
||
istObjekt(wert.stand) &&
|
||
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||
)
|
||
case 'installer-starten':
|
||
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||
case 'metadaten-vorschlaege':
|
||
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
||
if (!istObjekt(wert)) return false
|
||
return (
|
||
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
||
typeof wert.tmdbId === 'number' &&
|
||
typeof wert.titel === 'string' &&
|
||
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
||
typeof wert.posterPfad === 'string'
|
||
)
|
||
}
|
||
|
||
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||
if (!istObjekt(wert)) return false
|
||
return (
|
||
typeof wert.id === 'string' &&
|
||
typeof wert.phase === 'string' &&
|
||
typeof wert.prozent === 'number' &&
|
||
typeof wert.text === 'string' &&
|
||
Array.isArray(wert.dateien) &&
|
||
typeof wert.lesefehler === 'boolean'
|
||
)
|
||
}
|
||
|
||
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||
if (!istObjekt(wert)) return false
|
||
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||
if (wert.ok === false) return typeof wert.fehler === 'string'
|
||
return false
|
||
}
|
||
|
||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||
if (!istObjekt(wert)) return false
|
||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||
return typeof wert.id === 'string' && wert.id.length > 0
|
||
}
|
||
if (wert.art === 'einstellung-setzen') {
|
||
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
||
}
|
||
if (wert.art === 'metadaten-suchen') {
|
||
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
||
}
|
||
if (wert.art === 'zuordnung-setzen') {
|
||
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||
}
|
||
return (
|
||
wert.art === 'einstellungen-laden' ||
|
||
wert.art === 'werkzeuge-laden' ||
|
||
wert.art === 'bibliothek-laden' ||
|
||
wert.art === 'makemkv-update' ||
|
||
wert.art === 'schluessel-pruefen'
|
||
)
|
||
}
|
||
|
||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||
if (!istObjekt(wert)) return false
|
||
if (wert.art === 'fenster-port') return true
|
||
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||
return false
|
||
}
|