diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 0f42bc8..43dddce 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -1,411 +1,638 @@ -// 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/ (Jahr)/ (Jahr).mkv - * extra → Filme/ (Jahr)/extras/… (Jellyfin/Emby/Kodi) - * folge → Serien//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 } - | { 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 { - 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).id === 'string' && - typeof (wert.info as Record).titel === 'string' && - typeof (wert.info as Record).confidence === 'number' - ) - case 'disc-titel': - return ( - istObjekt(wert.stand) && - typeof (wert.stand as Record).id === 'string' && - typeof (wert.stand as Record).laeuft === 'boolean' && - Array.isArray((wert.stand as Record).titel) - ) - case 'einstellungen': - return istObjekt(wert.werte) - case 'werkzeuge': - return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record).presets) - case 'bibliothek': - return Array.isArray(wert.eintraege) - case 'schluessel-status': - return istObjekt(wert.stand) && typeof (wert.stand as Record).text === 'string' - case 'beschaffung-status': - return ( - istObjekt(wert.stand) && - typeof (wert.stand as Record).werkzeug === 'string' && - typeof (wert.stand as Record).text === 'string' && - typeof (wert.stand as Record).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 === 'titel-lesen' || - wert.art === 'rip-start' || - wert.art === 'rip-abbruch' || - wert.art === 'rip-ueberspringen' - ) { - return typeof wert.id === 'string' && wert.id.length > 0 - } - if (wert.art === 'einstellung-setzen') { - return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string' - } - if (wert.art === 'metadaten-suchen') { - return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string' - } - if (wert.art === 'zuordnung-setzen') { - return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag) - } - return ( - wert.art === 'einstellungen-laden' || - wert.art === 'werkzeuge-laden' || - wert.art === 'bibliothek-laden' || - wert.art === 'makemkv-update' || - wert.art === 'schluessel-pruefen' - ) -} - -export function istHauptNachricht(wert: unknown): wert is HauptNachricht { - if (!istObjekt(wert)) return false - if (wert.art === 'fenster-port') return true - if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string' - return false -} +// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1). +// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese +// Datei läuft im Fenster UND im Kern. +// +// Wege: +// Kern → Haupt über process.parentPort (KernNachricht) +// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht) +// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus) +// Haupt → Kern über utilityProcess.postMessage (HauptNachricht) +// +// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union +// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die +// Nachricht, und niemand erfährt es. + +/** 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 + /** Der Vorgang, zu dem dieser Rip gehört (5.4.0) — die Kompression läuft + * danach unter dieser Kennung in der Warteschlange weiter. */ + vorgangId?: string +} + +/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird: + * hauptfilm → Filme/ (Jahr)/ (Jahr).mkv + * extra → Filme/ (Jahr)/extras/… (Jellyfin/Emby/Kodi) + * folge → Serien//Season NN/… S01E02.mkv + * EINE Definition für Kern und Fenster. */ +export type TitelRolle = 'hauptfilm' | 'extra' | 'folge' + +/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat + * (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren + * dieser Sprache es gibt (Stereo + 5.1 = 2). */ +export interface SprachEintrag { + lang: string + sprache: string + spuren: number +} + +/** 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 + /** Größe laut Info-Lauf — gewichtet den durchgehenden Fortschrittsbalken + * (5.4.0). Fehlt: alle Titel zählen gleich. */ + groesseBytes?: number + /** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche + * Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */ + audioSprachen?: string[] + untertitelSprachen?: string[] +} + +/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0): + * Staffel und erste Folge von Hand — Rippy rät bewusst nicht, aber der + * Nutzer weiß, dass auf Disc 2 die Folgen 3–4 sind. */ +export interface RipAuftragOptionen { + /** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */ + staffel?: number | null + /** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend: + * Zuordnung über die TMDb-Laufzeiten wie bisher. */ + ersteFolge?: number | null +} + +/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */ +export interface DiscTitelVorschlag { + nr: number + dauerS: number + groesseBytes: number + kapitel: number + /** Von Rippy vorausgewählt. */ + gewaehlt: boolean + /** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */ + rolle: TitelRolle + /** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */ + grund: string + /** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */ + sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } +} + +/** 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 +} + +/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */ +export interface Darsteller { + name: string + rolle: string + /** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */ + bildPfad: 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 + /** 'movie' | 'tv' | '' — was die Zuordnung sagt. */ + typ: string + // ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ── + /** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */ + logoPfad: string + tagline: string + /** TMDb vote_average (0 = keine). */ + bewertung: number + /** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */ + fsk: string + laufzeitMinuten: number + genres: string[] + besetzung: Darsteller[] + /** Was Rippy NACH dem Info-Lauf noch dazugelernt hat („Laufzeit + * bestätigt: 124 min"), Klartext-Zeilen. */ + hinweise: string[] +} + +/** 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 +} + +// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ───────────────────── +/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die + * Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER + * verschoben). */ +export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen' + +/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen → Roh-Dateien, + * Übersicht → Kompression). */ +export interface VorgangEintrag { + id: string + laufwerk: string + typ: string + titel: string + jahr: number | null + phase: VorgangPhase + begonnen: string + beendet: string | null + rohOrdner: string + /** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */ + rohBytes: number + rohGeloescht: boolean + zielOrdner: string + fertigBytes: number + komprimiert: boolean + preset: string + fehler: string + titelZahl: number + /** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */ + neuKomprimierbar: boolean + /** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */ + faelligAm: string +} + +export interface KompressionStand { + vorgangId: string + titel: string + /** 0–100; -1 = keine Angabe. */ + prozent: number + text: string + phase: VorgangPhase +} + +/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor + * 5.4.0 oder von Hand hineingelegt) — nur von Hand löschbar. */ +export interface FremderOrdner { + ordner: string + bytes: number +} + +export interface VorgaengeStand { + vorgaenge: VorgangEintrag[] + /** Summe aller Roh-Ordner, die noch da sind. */ + rohGesamtBytes: number + fremde: FremderOrdner[] + /** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */ + regel: string + kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[] } +} + +/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */ +export interface LaufwerkBericht { + /** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */ + urteil: string + text: string + fehlerhafteBloecke: number + controllerfehler: number + sonstige: number + /** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */ + letzte: string[] + /** Betrachteter Zeitraum in Stunden. */ + stunden: number +} + +/** 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 } + /** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): „Roh- + * Ordner von X gelöscht", „Kompression eingereiht" — Auskunft, kein Alarm. */ + | { art: 'kern-hinweis'; 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 } + | { 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 + } + } + /** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange + * (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */ + | { art: 'vorgaenge'; stand: VorgaengeStand } + /** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */ + | { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string } + | { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht } + +/** 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[]; optionen?: RipAuftragOptionen } + | { 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' } + // ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ── + | { art: 'vorgaenge-laden' } + /** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */ + | { art: 'vorgang-roh-loeschen'; vorgangId: string } + /** „Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel + * kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */ + | { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string } + /** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */ + | { art: 'kompression-abbrechen'; vorgangId: string } + /** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */ + | { art: 'roh-aufraeumen' } + /** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */ + | { art: 'fremden-ordner-loeschen'; ordner: string } + /** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */ + | { art: 'laufwerk-pruefen'; id: string } + +/** 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 { + return typeof wert === 'object' && wert !== null +} + +function istText(wert: unknown): wert is string { + return typeof wert === 'string' +} + +function istKennung(wert: unknown): wert is string { + return typeof wert === 'string' && wert.length > 0 +} + +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': + case 'kern-hinweis': + 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).id === 'string' && + typeof (wert.info as Record).titel === 'string' && + typeof (wert.info as Record).confidence === 'number' + ) + case 'disc-titel': + return ( + istObjekt(wert.stand) && + typeof (wert.stand as Record).id === 'string' && + typeof (wert.stand as Record).laeuft === 'boolean' && + Array.isArray((wert.stand as Record).titel) + ) + case 'einstellungen': + return istObjekt(wert.werte) + case 'werkzeuge': + return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record).presets) + case 'bibliothek': + return Array.isArray(wert.eintraege) + case 'schluessel-status': + return istObjekt(wert.stand) && typeof (wert.stand as Record).text === 'string' + case 'beschaffung-status': + return ( + istObjekt(wert.stand) && + typeof (wert.stand as Record).werkzeug === 'string' && + typeof (wert.stand as Record).text === 'string' && + typeof (wert.stand as Record).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) + case 'vorgaenge': + return ( + istObjekt(wert.stand) && + Array.isArray((wert.stand as Record).vorgaenge) && + typeof (wert.stand as Record).rohGesamtBytes === 'number' && + istObjekt((wert.stand as Record).kompression) + ) + case 'kompression-ergebnis': + return ( + istKennung(wert.vorgangId) && + typeof wert.titel === 'string' && + typeof wert.ok === 'boolean' && + typeof wert.text === 'string' + ) + case 'laufwerk-gesundheit': + return ( + istKennung(wert.id) && + istObjekt(wert.bericht) && + typeof (wert.bericht as Record).urteil === 'string' && + typeof (wert.bericht as Record).text === 'string' && + Array.isArray((wert.bericht as Record).letzte) + ) + 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 +} + +/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt — die Sprachlisten + * sind optional, aber wenn sie da sind, müssen es Textlisten sein. */ +export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel { + if (!istObjekt(wert)) return false + if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false + if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false + if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false + for (const feld of ['audioSprachen', 'untertitelSprachen']) { + const liste = wert[feld] + if (liste === undefined) continue + if (!Array.isArray(liste) || !liste.every(istText)) return false + } + return true +} + +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 === 'rip-start') { + if (!istKennung(wert.id)) return false + if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) { + return false + } + if (wert.optionen !== undefined) { + if (!istObjekt(wert.optionen)) return false + const o = wert.optionen + const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number' + if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false + } + return true + } + if ( + wert.art === 'auswerfen' || + wert.art === 'titel-lesen' || + wert.art === 'rip-abbruch' || + wert.art === 'rip-ueberspringen' || + wert.art === 'laufwerk-pruefen' + ) { + return istKennung(wert.id) + } + if (wert.art === 'einstellung-setzen') { + return istKennung(wert.schluessel) && typeof wert.wert === 'string' + } + if (wert.art === 'metadaten-suchen') { + return istKennung(wert.id) && typeof wert.text === 'string' + } + if (wert.art === 'zuordnung-setzen') { + return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag) + } + if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') { + return istKennung(wert.vorgangId) + } + if (wert.art === 'vorgang-neu-komprimieren') { + return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string') + } + if (wert.art === 'fremden-ordner-loeschen') { + return istKennung(wert.ordner) + } + return ( + wert.art === 'einstellungen-laden' || + wert.art === 'werkzeuge-laden' || + wert.art === 'bibliothek-laden' || + wert.art === 'makemkv-update' || + wert.art === 'schluessel-pruefen' || + wert.art === 'vorgaenge-laden' || + wert.art === 'roh-aufraeumen' + ) +} + +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 +} diff --git a/rippy-windows/src/kern/ablage/rohaufraeumen.ts b/rippy-windows/src/kern/ablage/rohaufraeumen.ts new file mode 100644 index 0000000..bd51835 --- /dev/null +++ b/rippy-windows/src/kern/ablage/rohaufraeumen.ts @@ -0,0 +1,198 @@ +// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe. +// +// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte +// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im +// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders +// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression". +// +// Drei Sicherungen, alle bewusst: +// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und +// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben +// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen. +// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht +// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige +// Weg, wie Rippy Daten verlieren könnte. +// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0), +// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers. +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux. +import { join } from 'node:path' +import type { FremderOrdner } from '../../gemeinsam/nachrichten' + +export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number } + +/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */ +export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung' +export const STANDARD_AUFBEWAHRUNG = '7' + +/** 'behalten' | 'sofort' | '' → Regel. Unbekanntes = Vorgabe. */ +export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel { + const text = (wert ?? '').trim().toLowerCase() + if (text === 'behalten' || text === 'nie') return { art: 'behalten' } + if (text === 'sofort' || text === '0') return { art: 'sofort' } + const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG) + if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage } + return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) } +} + +export function regelText(regel: AufbewahrungsRegel): string { + if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.' + if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.' + return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.` +} + +/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */ +export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string { + if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return '' + const beendet = new Date(beendetIso) + if (Number.isNaN(beendet.getTime())) return '' + if (regel.art === 'sofort') return beendet.toISOString() + return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString() +} + +/** Das, was das Aufräumen über einen Vorgang wissen muss. */ +export interface AufraeumKandidat { + id: string + phase: string + komprimiert: boolean + rohOrdner: string + rohGeloeschtAm: string | null + beendet: string | null + fertigeDateien: readonly string[] +} + +export interface AufraeumZugriff { + existiert: (pfad: string) => boolean + /** Dateigröße in Bytes; 0 wenn nicht lesbar. */ + groesse: (pfad: string) => number + ordnerLoeschen: (pfad: string) => void +} + +export const ECHTER_ZUGRIFF: AufraeumZugriff = { + existiert: existsSync, + groesse: (pfad) => { + try { + return statSync(pfad).size + } catch { + // Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht. + return 0 + } + }, + ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }), +} + +/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */ +export function loeschHindernis( + v: AufraeumKandidat, + regel: AufbewahrungsRegel, + jetzt: Date, + zugriff: AufraeumZugriff = ECHTER_ZUGRIFF, +): string { + if (v.rohGeloeschtAm !== null) return 'schon gelöscht' + if (v.phase !== 'fertig') return `nicht fertig (${v.phase})` + if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis' + if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg' + if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt' + for (const datei of v.fertigeDateien) { + if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}` + if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}` + } + if (regel.art === 'behalten') return 'Regel: behalten' + const frist = faelligAm(v.beendet, regel) + if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt' + if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}` + return '' +} + +export interface AufraeumErgebnis { + geloescht: string[] + uebersprungen: Array<{ id: string; grund: string }> + fehler: Array<{ id: string; grund: string }> +} + +/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */ +export function aufraeumen( + vorgaenge: readonly AufraeumKandidat[], + regel: AufbewahrungsRegel, + jetzt: Date, + zugriff: AufraeumZugriff = ECHTER_ZUGRIFF, +): AufraeumErgebnis { + const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] } + for (const v of vorgaenge) { + const hindernis = loeschHindernis(v, regel, jetzt, zugriff) + if (hindernis.length > 0) { + ergebnis.uebersprungen.push({ id: v.id, grund: hindernis }) + continue + } + try { + zugriff.ordnerLoeschen(v.rohOrdner) + ergebnis.geloescht.push(v.id) + } catch (fehler) { + // R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird + // gesagt — beim nächsten Takt kommt er wieder dran. + ergebnis.fehler.push({ id: v.id, grund: String(fehler) }) + } + } + return ergebnis +} + +/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */ +export function ordnerGroesse(pfad: string): number { + let summe = 0 + const gehen = (ordner: string): void => { + let eintraege: import('node:fs').Dirent[] + try { + eintraege = readdirSync(ordner, { withFileTypes: true }) + } catch { + // Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie + // erfunden. + return + } + for (const e of eintraege) { + const voll = join(ordner, e.name) + if (e.isDirectory()) gehen(voll) + else { + try { + summe += statSync(voll).size + } catch { + // siehe oben + } + } + } + } + if (existsSync(pfad)) gehen(pfad) + return summe +} + +/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */ +export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet): FremderOrdner[] { + if (!existsSync(rohWurzel)) return [] + const liste: FremderOrdner[] = [] + let namen: string[] + try { + namen = readdirSync(rohWurzel, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + } catch (fehler) { + // R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste + // bleibt dann leer, weil wirklich nichts gesehen wurde. + console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`) + return liste + } + for (const name of namen) { + const voll = join(rohWurzel, name) + if (bekannte.has(voll) || bekannte.has(name)) continue + liste.push({ ordner: voll, bytes: ordnerGroesse(voll) }) + } + return liste.sort((a, b) => b.bytes - a.bytes) +} + +/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche + * darf „fremden Ordner löschen" anfassen — nie etwas anderes. */ +export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean { + const wurzel = rohWurzel.replace(/[\\/]+$/, '') + const kandidat = pfad.replace(/[\\/]+$/, '') + if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false + const rest = kandidat.slice(wurzel.length) + return /^[\\/][^\\/]+$/.test(rest) +} diff --git a/rippy-windows/src/kern/ablauf/ablegen.ts b/rippy-windows/src/kern/ablauf/ablegen.ts new file mode 100644 index 0000000..5640cba --- /dev/null +++ b/rippy-windows/src/kern/ablauf/ablegen.ts @@ -0,0 +1,393 @@ +// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der +// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft: +// * in der Warteschlange, während das Laufwerk schon die nächste Disc +// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige +// noch komprimiert"), +// * und Tage später noch einmal — „Wiederholen ab Kompression": Die +// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein +// Neulesen mehr. +// +// Was hier NEU ist gegenüber 5.3.x: +// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh- +// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek. +// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen. +// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe. +// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob +// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll. +// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei; +// TMDb gibt dann nur noch eine Warnung. +// * Durchgehender Fortschritt, nach Dateigröße gewichtet. +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. +import { basename, join } from 'node:path' +import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten' +import { baueNfo } from '../ablage/nfo' +import { bibliothekRefresh } from '../ablage/medienserver' +import { posterSpeichern } from '../ablage/poster' +import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur' +import { komprimieren } from '../komprimieren/handbrake' +import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' +import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan' +import { + folgenNummern, + folgenPlausibel, + gewichteterFortschritt, + sprachwahl, + type KompressionsAuftrag, +} from './auftrag' + +export interface AblageUmgebung { + /** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */ + handbrake: () => string + /** Wurzel der Ablage. */ + ablage: () => string + einstellungen: () => Einstellungen + /** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */ + presetEmpfehlung?: (discTyp: string) => string + /** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */ + episodenLaufzeiten?: ( + tmdbId: number, + staffel: number, + ) => Promise> + bibliothekEintragen?: (eintrag: BibliothekEintrag) => void + jetzt?: () => Date + /** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */ + verschieben?: (von: string, nach: string) => void + /** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */ + spurenScannen?: (programm: string, datei: string) => Promise +} + +export interface AblageErgebnis { + status: 'fertig' | 'fehler' | 'abgebrochen' + zielOrdner: string + dateien: string[] + text: string + komprimiert: boolean + preset: string + fertigBytes: number + hinweise: string[] +} + +export type AblageMeldung = (stand: Pick) => void + +/** Verschieben auf demselben Datenträger ist ein Umbenennen; über + * Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */ +export function dateiVerschieben(von: string, nach: string): void { + try { + renameSync(von, nach) + } catch (fehler) { + const code = (fehler as NodeJS.ErrnoException).code + if (code !== 'EXDEV') throw fehler + copyFileSync(von, nach) + unlinkSync(von) + } +} + +function groesseVon(pfad: string): number { + try { + return statSync(pfad).size + } catch { + // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am + // Ergebnis selbst. + return 0 + } +} + +export async function komprimierenUndAblegen( + auftrag: KompressionsAuftrag, + umgebung: AblageUmgebung, + melden: AblageMeldung, + abbruch: AbortSignal, +): Promise { + const einstellungen = umgebung.einstellungen() + const handbrake = umgebung.handbrake() + const soll = komprimierenFuer(auftrag.discTyp, einstellungen) + const komprimiert = soll && handbrake.length > 0 + const preset = + auftrag.preset.length > 0 + ? auftrag.preset + : presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '') + const zuordnung = auftrag.zuordnung + const hinweise: string[] = [...auftrag.hinweise] + const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad)) + + // ── WOHIN? (§ 4.2) ────────────────────────────────────────────────── + // Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter + // Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag + // Folgen enthält (der Nutzer hat es im Dialog so bestätigt). + const alsSerie = dateien.some((d) => d.rolle === 'folge') + const staffel = auftrag.staffel ?? 1 + const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId + const fertigOrdner = + auftrag.zielOrdner.length > 0 + ? auftrag.zielOrdner + : alsSerie + ? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel) + : zuordnung !== null && zuordnung.titel.length > 0 + ? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId) + : zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId) + mkdirSync(fertigOrdner, { recursive: true }) + + // Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen + // Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film. + const extrasOrdner = join(fertigOrdner, 'extras') + const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner) + if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true }) + + const global = { + audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')), + untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')), + } + const gewichte = dateien.map((d) => d.groesseBytes) + const scannen = umgebung.spurenScannen ?? spurenScannen + const verschieben = umgebung.verschieben ?? dateiVerschieben + + const fertigeDateien: string[] = [] + // Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der + // Episoden-Zuordnung weiter unten. + const folgenSekunden: number[] = [] + for (let i = 0; i < dateien.length; i++) { + const datei = dateien[i] + const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad)) + if (abbruch.aborted) { + return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise) + } + if (!komprimiert) { + melden({ + phase: 'legt-ab', + prozent: gewichteterFortschritt(gewichte, i, 0), + text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`, + }) + try { + verschieben(datei.pfad, ausgabe) + } catch (fehler) { + return { + status: 'fehler', + zielOrdner: fertigOrdner, + dateien: fertigeDateien, + text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`, + komprimiert: false, + preset: '', + fertigBytes: 0, + hinweise, + } + } + fertigeDateien.push(ausgabe) + if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS) + continue + } + + const wahl = sprachwahl(datei, global) + // Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind + // (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die + // globale Vorgabe war schon immer „was da ist". + if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) { + const spuren = await scannen(handbrake, datei.pfad) + if (spuren !== null) { + const fehlt = fehlendeSprachen(spuren, wahl) + if (fehlt.audio.length > 0) { + hinweise.push( + `${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`, + ) + } + if (fehlt.untertitel.length > 0) { + hinweise.push( + `${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`, + ) + } + } else { + hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`) + } + } + + melden({ + phase: 'komprimiert', + prozent: gewichteterFortschritt(gewichte, i, 0), + text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${ + wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : '' + }${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`, + }) + const encode = await komprimieren({ + programm: handbrake, + eingabe: datei.pfad, + ausgabe, + preset, + audioSprachen: wahl.audio, + untertitelSprachen: wahl.untertitel, + untertitelStandard: wahl.untertitelStandard, + abbruch, + aufFortschritt: (prozent) => { + melden({ + phase: 'komprimiert', + prozent: gewichteterFortschritt(gewichte, i, prozent), + text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`, + }) + }, + }) + if (encode.status === 'cancelled') { + return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise) + } + if (encode.status === 'error') { + return { + status: 'fehler', + zielOrdner: fertigOrdner, + dateien: fertigeDateien, + text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`, + komprimiert: true, + preset, + fertigBytes: 0, + hinweise, + } + } + fertigeDateien.push(encode.ausgabe) + if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS) + } + + // ── Serien-Staffelablage (§ 4.2) ──────────────────────────────────── + // Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit- + // Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird + // umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch + // (die offene Wunde #395 von ARM war genau dieses Ratespiel). + if (alsSerie && folgenSekunden.length > 0) { + const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null + const laufzeiten = + tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : [] + let episoden: number[] | null = null + if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) { + episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge) + hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`) + hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten)) + } else { + episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null + } + if (episoden !== null) { + hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden)) + } else { + hinweise.push( + laufzeiten.length === 0 + ? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.' + : 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.', + ) + } + } + + // ── Ablage: NFO und Poster daneben ─────────────────────────────────── + melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' }) + const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId + try { + const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo' + writeFileSync( + join(fertigOrdner, nfoName), + baueNfo( + { + typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film', + beschreibung: zuordnung?.meta.beschreibung, + genres: zuordnung?.meta.genres, + laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, + quelle: + zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', + }, + anzeigeTitel, + zuordnung?.jahr ?? null, + ), + ) + } catch (fehler) { + // Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es + // wird GESAGT (R4). + hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`) + } + if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { + await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) + } + + const server = String(einstellungen['mediaServer'] ?? '') + const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') + const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') + const refresh = await bibliothekRefresh(server, serverUrl, serverKey) + + // Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN + // Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es + // unter dem alten Namen nicht mehr gibt. + const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0) + const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner) + let fertigBytes = 0 + for (const datei of endgueltig) fertigBytes += groesseVon(datei) + + // § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt. + const jetzt = umgebung.jetzt?.() ?? new Date() + if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) { + const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt + umgebung.bibliothekEintragen({ + fingerprint: auftrag.fingerprint, + titel: anzeigeTitel, + jahr: zuordnung?.jahr ?? null, + typ: auftrag.discTyp, + groesseBytes: fertigBytes, + ablageort: fertigOrdner, + datum: begonnen.toISOString(), + dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)), + posterPfad: zuordnung?.meta.posterPfad ?? '', + }) + } + + const text = + (komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) + + (refresh.length > 0 ? ` ${refresh}` : '') + + (auftrag.lesefehler + ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' + : '') + return { + status: 'fertig', + zielOrdner: fertigOrdner, + dateien: endgueltig, + text, + komprimiert, + preset: komprimiert ? preset : '', + fertigBytes, + hinweise, + } +} + +function abgebrochen( + zielOrdner: string, + dateien: string[], + preset: string, + komprimiert: boolean, + hinweise: string[], +): AblageErgebnis { + return { + status: 'abgebrochen', + zielOrdner, + dateien, + text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.', + komprimiert, + preset, + fertigBytes: 0, + hinweise, + } +} + +/** Alle MKVs im Zielordner (und in extras\) — nach dem Umbenennen der Folgen + * ist das die einzige verlässliche Liste. */ +function vorhandeneMkv(ordner: string, extras: string): string[] { + const liste: string[] = [] + for (const o of [ordner, extras]) { + if (!existsSync(o)) continue + try { + for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name)) + } catch { + // Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek + // zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg. + } + } + return liste.sort() +} diff --git a/rippy-windows/src/kern/ablauf/auftrag.ts b/rippy-windows/src/kern/ablauf/auftrag.ts new file mode 100644 index 0000000..874a77f --- /dev/null +++ b/rippy-windows/src/kern/ablauf/auftrag.ts @@ -0,0 +1,177 @@ +// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz. +// +// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt +// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien +// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht +// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank +// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder +// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray). +import type { TitelRolle } from '../../gemeinsam/nachrichten' +import type { Zuordnung } from '../metadaten/zuordnung' + +export interface RohDatei { + pfad: string + titelNr: number + rolle: TitelRolle + dauerS: number + /** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */ + groesseBytes: number + /** Sprachen je Datei (leer = die globale Einstellung gilt). */ + audioSprachen: string[] + untertitelSprachen: string[] +} + +export interface KompressionsAuftrag { + vorgangId: string + laufwerk: string + discTyp: string + rohOrdner: string + dateien: RohDatei[] + /** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */ + zuordnung: Zuordnung | null + /** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */ + staffel: number | null + ersteFolge: number | null + discNr: number | null + fingerprint: string | null + lesefehler: boolean + /** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */ + hinweise: string[] + /** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */ + begonnen: string + /** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */ + preset: string + /** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs — die neue + * Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen. + * '' = Zielordner wird frisch bestimmt. */ + zielOrdner: string +} + +export function auftragZuJson(auftrag: KompressionsAuftrag): string { + return JSON.stringify(auftrag) +} + +/** Liest einen gemerkten Auftrag zurück — null, wenn er nicht die Form hat + * (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */ +export function auftragAusJson(text: string): KompressionsAuftrag | null { + let roh: unknown + try { + roh = JSON.parse(text) + } catch { + return null + } + if (typeof roh !== 'object' || roh === null) return null + const a = roh as Record + if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null + const dateien: RohDatei[] = [] + for (const d of a.dateien) { + if (typeof d !== 'object' || d === null) return null + const e = d as Record + if (typeof e.pfad !== 'string') return null + dateien.push({ + pfad: e.pfad, + titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0, + rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm', + dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0, + groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0, + audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [], + untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [], + }) + } + return { + vorgangId: a.vorgangId, + laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '', + discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray', + rohOrdner: a.rohOrdner, + dateien, + zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null, + staffel: typeof a.staffel === 'number' ? a.staffel : null, + ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null, + discNr: typeof a.discNr === 'number' ? a.discNr : null, + fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null, + lesefehler: a.lesefehler === true, + hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [], + begonnen: typeof a.begonnen === 'string' ? a.begonnen : '', + preset: typeof a.preset === 'string' ? a.preset : '', + zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '', + } +} + +/** Was HandBrake für EINE Datei bekommt. */ +export interface SprachWahl { + audio: string[] + untertitel: string[] + /** Die erste Untertitelspur vorwählen — weil der Ton nicht in der + * Wunschsprache ist. */ + untertitelStandard: boolean +} + +/** + * Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt + * die globale Einstellung. Und die Regel des Commanders (01.09.2026): + * „japanischer Ton und braucht dringend die deutschen untertitel" — + * spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten + * globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach + * vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in + * Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen, + * komprimieren/handbrake.ts). + */ +export function sprachwahl( + datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] }, + global: { audio: readonly string[]; untertitel: readonly string[] }, +): SprachWahl { + const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio] + let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel] + const wunsch = global.audio[0] ?? '' + const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch + const untertitelStandard = tonFremd && untertitel.includes(wunsch) + if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)] + return { audio, untertitel, untertitelStandard } +} + +/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */ +export function folgenNummern(anzahl: number, ersteFolge: number): number[] { + const start = Math.max(1, Math.trunc(ersteFolge)) + return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i) +} + +/** + * Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur). + * Vorher sprang der Balken je Titel auf null zurück — bei 2 × 16 GB stand + * er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle + * Teile gleich. `prozent` < 0 heißt „vom laufenden Teil noch keine Angabe". + */ +export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number { + if (gewichte.length === 0) return -1 + const gleich = gewichte.some((g) => !(g > 0)) + const g = gleich ? gewichte.map(() => 1) : [...gewichte] + const gesamt = g.reduce((s, w) => s + w, 0) + if (gesamt <= 0) return -1 + const i = Math.min(Math.max(0, index), g.length - 1) + const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0) + const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100 + return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100))) +} + +/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine + * Warnung — der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext + * je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */ +export function folgenPlausibel( + dateiSekunden: readonly number[], + nummern: readonly number[], + laufzeiten: ReadonlyArray, + toleranz = 0.15, +): string[] { + const warnungen: string[] = [] + for (let i = 0; i < nummern.length; i++) { + const soll = laufzeiten.find(([nr]) => nr === nummern[i]) + const ist = dateiSekunden[i] ?? 0 + if (soll === undefined || soll[1] <= 0 || ist <= 0) continue + if (Math.abs(ist - soll[1]) / soll[1] > toleranz) { + warnungen.push( + `Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`, + ) + } + } + return warnungen +} diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 7ea3f0a..7ae6c6a 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -1,700 +1,614 @@ -// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. -// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über -// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und -// hängen damit an der Prozess-Leine (R6). -// -// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein -// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in -// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der -// Einstellung im UI (W-5) eine Stimme. -import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' -// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. -import { basename, join } from 'node:path' -import type { - BibliothekEintrag, - GeraeteInfo, - RipAuftragTitel, - RipStatus, - TitelRolle, -} from '../../gemeinsam/nachrichten' -import { baueNfo } from '../ablage/nfo' -import { bibliothekRefresh } from '../ablage/medienserver' -import { posterSpeichern } from '../ablage/poster' -import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur' -import { komprimieren } from '../komprimieren/handbrake' -import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' -import type { Zuordnung } from '../metadaten/zuordnung' -import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' -import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' -import { isoSichern } from '../rip/iso' -import { rippen } from '../rip/makemkv' -import { spawn } from 'node:child_process' -import { mkdtempSync, rmSync, createWriteStream } from 'node:fs' -import { tmpdir } from 'node:os' - -interface LaufenderVorgang { - /** Beendet den GANZEN Auftrag. */ - abbruch: AbortController - /** Beendet nur den gerade laufenden Titel (überspringen). */ - titelAbbruch?: AbortController - /** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */ - uebersprungen?: boolean - status: RipStatus -} - -export interface PipelineUmgebung { - /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ - makemkvcon: () => string - /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ - handbrake: () => string - /** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */ - flac: () => string - /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ - audioToc?: (id: string) => Spur[] - /** Rohe Audio-Sektoren lesen (W-7). */ - audioSpurLesen?: ( - id: string, - start: number, - sektoren: number, - schreiben: (daten: Buffer) => void, - fortschritt?: (anteil: number) => void, - ) => number - /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ - geraet: (id: string) => GeraeteInfo | null - /** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ - ablage: () => string - einstellungen: () => Einstellungen - /** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ — - * greift nur, wenn der Nutzer nichts eingestellt hat. */ - presetEmpfehlung?: (discTyp: string) => string - melden: (status: RipStatus) => void - /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ - zuordnung?: (id: string) => Zuordnung | null - /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ - fingerprint?: (id: string) => string | null - /** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */ - discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null - /** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. - * Injiziert, damit die Pipeline ohne Netz prüfbar bleibt. */ - episodenLaufzeiten?: ( - tmdbId: number, - staffel: number, - ) => Promise> - /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */ - bibliothekEintragen?: (eintrag: BibliothekEintrag) => void - /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ - jetzt?: () => Date -} - -export class Pipeline { - private laufend = new Map() - - constructor(private readonly umgebung: PipelineUmgebung) {} - - laeuft(id: string): boolean { - return this.laufend.has(id) - } - - private melden(status: RipStatus): void { - const eintrag = this.laufend.get(status.id) - if (eintrag !== undefined) eintrag.status = status - this.umgebung.melden(status) - } - - private fehler(id: string, text: string): RipStatus { - return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } - } - - /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten - * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: - * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, - * während Folge 2 heil daneben lag). */ - ueberspringen(id: string): void { - const vorgang = this.laufend.get(id) - if (vorgang === undefined || vorgang.titelAbbruch === undefined) return - vorgang.uebersprungen = true - vorgang.titelAbbruch.abort() - } - - /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ - async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise { - if (this.laufend.has(id)) { - this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) - return - } - - const programm = this.umgebung.makemkvcon() - if (programm.length === 0) { - this.umgebung.melden( - this.fehler( - id, - 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)', - ), - ) - return - } - - const geraet = this.umgebung.geraet(id) - if (geraet === null) { - this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`)) - return - } - // Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt - // einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der - // Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf - // nie zu „es geht nicht" werden. - if (geraet.status === 'empty') { - this.umgebung.melden( - this.fehler( - id, - `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`, - ), - ) - return - } - - const abbruch = new AbortController() - const startZeit = this.umgebung.jetzt?.() ?? new Date() - const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) - const vorgangsName = `${geraet.typ}-${zeit}` - - // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). - if (geraet.typ === 'cd') { - this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) - this.melden(this.laufend.get(id)!.status) - await this.audioCdRippen(id, geraet, startZeit, abbruch) - return - } - if (geraet.typ === 'unknown') { - this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) - this.melden(this.laufend.get(id)!.status) - await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) - return - } - - const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) - mkdirSync(rohOrdner, { recursive: true }) - - // TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch - // 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau: - // * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt - // Trailer, Werbung und Alterskennzeichen. - // * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht - // mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der - // Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren. - const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all'] - // Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je - // Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten. - const rolleJeDatei = new Map() - const dauerJeDatei = new Map() - const alleDateien = new Set() - let lesefehlerGesamt = false - const gescheitert: string[] = [] - const uebersprungen: string[] = [] - - this.laufend.set(id, { - abbruch, - status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false }, - }) - - for (let i = 0; i < laeufe.length; i++) { - const welcher = laeufe[i] - // Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf, - // „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch. - const titelAbbruch = new AbortController() - const weiterreichen = (): void => titelAbbruch.abort() - abbruch.signal.addEventListener('abort', weiterreichen, { once: true }) - const vorgang = this.laufend.get(id) - if (vorgang !== undefined) { - vorgang.titelAbbruch = titelAbbruch - vorgang.uebersprungen = false - } - - const beschriftung = - welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})` - this.melden({ - id, - phase: 'rippt', - prozent: -1, - text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`, - dateien: [...alleDateien], - lesefehler: lesefehlerGesamt, - titelNr: i + 1, - titelGesamt: laeufe.length, - haengt: null, - }) - - const rip = await rippen({ - programm, - geraetepfad: geraet.pfad, - zielOrdner: rohOrdner, - titel: welcher, - abbruch: titelAbbruch.signal, - aufFortschritt: (prozent) => { - const alt = this.laufend.get(id)?.status - if (alt !== undefined) this.melden({ ...alt, prozent }) - }, - aufMeldung: (_code, text) => { - const alt = this.laufend.get(id)?.status - if (alt !== undefined) this.melden({ ...alt, text }) - }, - aufHaengt: (klemmt) => { - const alt = this.laufend.get(id)?.status - if (alt !== undefined) this.melden({ ...alt, haengt: klemmt }) - }, - }) - abbruch.signal.removeEventListener('abort', weiterreichen) - - if (rip.status === 'cancelled') { - // Nur dieser Titel — oder der ganze Auftrag? - if (this.laufend.get(id)?.uebersprungen !== true) { - this.laufend.delete(id) - this.melden({ - id, - phase: 'abgebrochen', - prozent: -1, - text: 'Abgebrochen.', - dateien: [...alleDateien], - lesefehler: lesefehlerGesamt, - }) - return - } - uebersprungen.push(beschriftung) - continue - } - - lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler - if (rip.status === 'error') { - // EIN kaputter Titel beendet den Auftrag nicht — die anderen sind - // vielleicht heil. Der Grund wird gesammelt und am Ende genannt. - gescheitert.push(`${beschriftung}: ${rip.fehler}`) - continue - } - // rippen() liefert den ganzen Zielordner zurück, also auch die - // Dateien der vorigen Titel — neu ist, was noch nicht bekannt war. - const neu = rip.dateien.filter((d) => !alleDateien.has(d)) - for (const d of rip.dateien) alleDateien.add(d) - // Ohne Auswahl ('all') gilt für alles die Film-Regel. - const rolle: TitelRolle = auftrag[i]?.rolle ?? 'hauptfilm' - for (const d of neu) { - rolleJeDatei.set(d, rolle) - dauerJeDatei.set(d, auftrag[i]?.dauerS ?? 0) - } - if (neu.length === 0) { - // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben - // (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert. - gescheitert.push(`${beschriftung}: keine Datei entstanden`) - } - } - - const nachLauf = this.laufend.get(id) - if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined - - if (alleDateien.size === 0) { - this.laufend.delete(id) - this.melden({ - id, - phase: 'fehler', - prozent: -1, - text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.', - dateien: [], - lesefehler: lesefehlerGesamt, - }) - return - } - - const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt } - - const lesefehlerHinweis = - (rip.lesefehler - ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' - : '') + - (uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') + - (gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '') - - // ── Kompression (W-3) ──────────────────────────────────────────────── - const einstellungen = this.umgebung.einstellungen() - const handbrake = this.umgebung.handbrake() - const soll = komprimierenFuer(geraet.typ, einstellungen) - - if (!soll || handbrake.length === 0) { - const grund = !soll - ? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.` - : 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.' - this.laufend.delete(id) - this.melden({ - id, - phase: 'fertig', - prozent: 100, - text: `Fertig. ${grund}${lesefehlerHinweis}`, - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - - const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '') - // Mit Metadaten (W-4) heißt der Ordner „ (Jahr)" — daran - // erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname. - const zuordnung = this.umgebung.zuordnung?.(id) ?? null - - // ── WOHIN? (§ 4.2) ────────────────────────────────────────────────── - // Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter - // Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag - // Folgen enthält (der Nutzer hat es im Dialog so bestätigt). - const alsSerie = auftrag.some((a) => a.rolle === 'folge') - const staffel = this.umgebung.discAngaben?.(id)?.staffel ?? 1 - const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName - const fertigOrdner = alsSerie - ? serienOrdner(join(this.umgebung.ablage(), 'Serien'), serienTitel, staffel) - : zuordnung !== null && zuordnung.titel.length > 0 - ? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName) - : zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName) - mkdirSync(fertigOrdner, { recursive: true }) - - // Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so - // lesen Jellyfin, Emby und Kodi sie als Zugaben und nicht als - // zweiten Film. Der Ordner entsteht nur, wenn es wirklich Extras gibt. - const extrasOrdner = join(fertigOrdner, 'extras') - const zielFuer = (roh: string): string => { - const rolle = rolleJeDatei.get(roh) ?? 'hauptfilm' - return rolle === 'extra' ? extrasOrdner : fertigOrdner - } - if (rip.dateien.some((d) => zielFuer(d) === extrasOrdner)) { - mkdirSync(extrasOrdner, { recursive: true }) - } - - const fertigeDateien: string[] = [] - // Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der - // Episoden-Zuordnung weiter unten. - const folgenSekunden: number[] = [] - for (let i = 0; i < rip.dateien.length; i++) { - const eingabe = rip.dateien[i] - const ausgabe = join(zielFuer(eingabe), basename(eingabe)) - const alt = this.laufend.get(id)?.status - if (alt !== undefined) { - this.melden({ - ...alt, - phase: 'komprimiert', - prozent: -1, - text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`, - }) - } - const encode = await komprimieren({ - programm: handbrake, - eingabe, - ausgabe, - preset, - audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')), - untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')), - abbruch: abbruch.signal, - aufFortschritt: (prozent) => { - const stand = this.laufend.get(id)?.status - if (stand !== undefined) { - this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) }) - } - }, - }) - if (encode.status === 'cancelled') { - this.laufend.delete(id) - this.melden({ - id, - phase: 'abgebrochen', - prozent: -1, - text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.', - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - if (encode.status === 'error') { - this.laufend.delete(id) - this.melden({ - id, - phase: 'fehler', - prozent: -1, - text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`, - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - fertigeDateien.push(encode.ausgabe) - if ((rolleJeDatei.get(eingabe) ?? 'hauptfilm') === 'folge') { - folgenSekunden.push(dauerJeDatei.get(eingabe) ?? 0) - } - } - - // ── Serien-Staffelablage (§ 4.2) ──────────────────────────────────── - // Umbenannt wird NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als - // falsch (die offene Wunde #395 von ARM war genau dieses Ratespiel). - // Die Laufzeiten der Staffel kommen von TMDb; ohne sie bleibt es beim - // MakeMKV-Namen, und Rippy SAGT das. - const ablageMeldungen: string[] = [] - if (alsSerie && folgenSekunden.length > 0) { - const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null - const laufzeiten = - tmdbId !== null ? ((await this.umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : [] - const episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null - if (episoden !== null) { - ablageMeldungen.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden)) - } else { - ablageMeldungen.push( - laufzeiten.length === 0 - ? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen.' - : 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.', - ) - } - } - - // ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ─────────── - const alt = this.laufend.get(id)?.status - if (alt !== undefined) { - this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien }) - } - try { - const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo' - writeFileSync( - join(fertigOrdner, nfoName), - baueNfo( - { - typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film', - beschreibung: zuordnung?.meta.beschreibung, - genres: zuordnung?.meta.genres, - laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, - quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', - }, - zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, - zuordnung?.jahr ?? null, - ), - ) - } catch (fehler) { - // Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es - // wird GESAGT (R4). - const stand = this.laufend.get(id)?.status - if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` }) - } - if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { - await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) - } - - const server = String(einstellungen['mediaServer'] ?? '') - const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') - const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') - const refresh = await bibliothekRefresh(server, serverUrl, serverKey) - - // § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt. - const fingerprint = this.umgebung.fingerprint?.(id) ?? null - if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { - let groesse = 0 - for (const datei of fertigeDateien) { - try { - groesse += statSync(datei).size - } catch { - // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts - // am Eintrag selbst. - } - } - this.umgebung.bibliothekEintragen({ - fingerprint, - titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, - jahr: zuordnung?.jahr ?? null, - typ: geraet.typ, - groesseBytes: groesse, - ablageort: fertigOrdner, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: zuordnung?.meta.posterPfad ?? '', - }) - } - - this.laufend.delete(id) - this.melden({ - id, - phase: 'fertig', - prozent: 100, - text: - `Fertig — komprimiert nach ${fertigOrdner}.` + - (refresh.length > 0 ? ` ${refresh}` : '') + - lesefehlerHinweis, - dateien: fertigeDateien, - lesefehler: rip.lesefehler, - }) - } - - abbrechen(id: string): void { - this.laufend.get(id)?.abbruch.abort() - } - - // ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ── - private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise { - const fertigMachen = (status: RipStatus): void => { - this.laufend.delete(id) - this.melden(status) - } - const flac = this.umgebung.flac() - if (flac.length === 0) { - fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).')) - return - } - if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) { - fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.')) - return - } - let spuren: Spur[] - try { - spuren = this.umgebung.audioToc(id).filter((s) => s.audio) - } catch (fehler) { - fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`)) - return - } - if (spuren.length === 0) { - fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?')) - return - } - - // MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne - // Treffer heißen die Dateien „Track 01" — das ist keine Ausrede, - // sondern die Wahrheit über eine Disc, die niemand kennt. - const kennung = kennungAusSpuren(spuren) - const antwort = await abfragen(kennung) - const tags = tagsJeSpur(antwort) - const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit) - const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName) - mkdirSync(ziel, { recursive: true }) - - const dateien: string[] = [] - for (let i = 0; i < spuren.length; i++) { - if (abbruch.signal.aborted) { - fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false }) - return - } - const spur = spuren[i] - const spurTags = tags.get(spur.nr) ?? {} - const name = dateiname(spur.nr, spurTags['TITLE'] ?? '') - const zielDatei = join(ziel, `${name}.flac`) - const stand = this.laufend.get(id)?.status - if (stand !== undefined) { - this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) }) - } - - // Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang - // hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht. - const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-')) - try { - const wav = join(arbeit, 'spur.wav') - const strom = createWriteStream(wav) - strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) - this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { - const s = this.laufend.get(id)?.status - if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) - }) - await new Promise((fertig, kaputt) => { - strom.end(() => fertig()) - strom.on('error', kaputt) - }) - const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags)) - if (flacErgebnis.code !== 0) { - fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`)) - return - } - } catch (fehler) { - fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`)) - return - } finally { - rmSync(arbeit, { recursive: true, force: true }) - } - dateien.push(zielDatei) - } - - const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) - if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { - let groesse = 0 - for (const datei of dateien) { - try { - groesse += statSync(datei).size - } catch { - // Anzeige-Komfort — siehe oben. - } - } - this.umgebung.bibliothekEintragen({ - fingerprint, - titel: ordnerName, - jahr: null, - typ: 'cd', - groesseBytes: groesse, - ablageort: ziel, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: '', - }) - } - fertigMachen({ - id, - phase: 'fertig', - prozent: 100, - text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''), - dateien, - lesefehler: false, - }) - } - - // ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ───────────────── - private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise { - const ziel = join(this.umgebung.ablage(), 'ISO') - mkdirSync(ziel, { recursive: true }) - const zielDatei = join(ziel, `${vorgangsName}.iso`) - const ergebnis = await isoSichern( - geraet.pfad, - zielDatei, - geraet.groesseBytes, - (prozent) => { - const stand = this.laufend.get(id)?.status - if (stand !== undefined) this.melden({ ...stand, prozent }) - }, - abbruch.signal, - ) - this.laufend.delete(id) - if (ergebnis.status === 'success') { - const fingerprint = this.umgebung.fingerprint?.(id) ?? null - if (fingerprint !== null) { - this.umgebung.bibliothekEintragen?.({ - fingerprint, - titel: vorgangsName, - jahr: null, - typ: 'iso', - groesseBytes: ergebnis.bytes, - ablageort: zielDatei, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: '', - }) - } - this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false }) - } else if (ergebnis.status === 'cancelled') { - this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) - } else { - this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false }) - } - } -} - -function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { - return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` -} - -function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { - return new Promise((fertig) => { - const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) - const stuecke: Buffer[] = [] - kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) - kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) - kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) - }) -} - -// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon -// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken. -export function kopierenStattEncode(eingabe: string, ausgabe: string): void { - copyFileSync(eingabe, ausgabe) -} +// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. +// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über +// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und +// hängen damit an der Prozess-Leine (R6). +// +// Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den +// RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions- +// Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts → +// ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien +// liegen — die nächste Disc kann rein, während die vorige noch rechnet +// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz +// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher +// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und +// nach einem Abbruch nachsehen. +import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs' +// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. +import { join } from 'node:path' +import type { + BibliothekEintrag, + GeraeteInfo, + RipAuftragOptionen, + RipAuftragTitel, + RipStatus, + TitelRolle, +} from '../../gemeinsam/nachrichten' +import type { Einstellungen } from '../komprimieren/presets' +import type { Zuordnung } from '../metadaten/zuordnung' +import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' +import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' +import { isoSichern } from '../rip/iso' +import { rippen } from '../rip/makemkv' +import { spawn } from 'node:child_process' +import { mkdtempSync, createWriteStream } from 'node:fs' +import { tmpdir } from 'node:os' +import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag' + +interface LaufenderVorgang { + /** Beendet den GANZEN Auftrag. */ + abbruch: AbortController + /** Beendet nur den gerade laufenden Titel (überspringen). */ + titelAbbruch?: AbortController + /** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */ + uebersprungen?: boolean + status: RipStatus +} + +/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */ +export interface VorgangBeginn { + id: string + laufwerk: string + typ: string + titel: string + jahr: number | null + rohOrdner: string + begonnen: string +} + +export interface PipelineUmgebung { + /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ + makemkvcon: () => string + /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ + handbrake: () => string + /** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */ + flac: () => string + /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ + audioToc?: (id: string) => Spur[] + /** Rohe Audio-Sektoren lesen (W-7). */ + audioSpurLesen?: ( + id: string, + start: number, + sektoren: number, + schreiben: (daten: Buffer) => void, + fortschritt?: (anteil: number) => void, + ) => number + /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ + geraet: (id: string) => GeraeteInfo | null + /** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ + ablage: () => string + einstellungen: () => Einstellungen + melden: (status: RipStatus) => void + /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ + zuordnung?: (id: string) => Zuordnung | null + /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ + fingerprint?: (id: string) => string | null + /** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */ + discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null + /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) — für + * Audio-CD und ISO, die keine Kompression durchlaufen. */ + bibliothekEintragen?: (eintrag: BibliothekEintrag) => void + /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ + jetzt?: () => Date + /** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht + * die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */ + vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number + /** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */ + vorgangBeginnt?: (vorgang: VorgangBeginn) => void + /** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */ + vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void + /** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) — die nächste + * Disc soll rein, während die Kompression läuft. */ + nachRipAuswerfen?: (id: string) => void +} + +export class Pipeline { + private laufend = new Map() + + constructor(private readonly umgebung: PipelineUmgebung) {} + + laeuft(id: string): boolean { + return this.laufend.has(id) + } + + private melden(status: RipStatus): void { + const eintrag = this.laufend.get(status.id) + if (eintrag !== undefined) eintrag.status = status + this.umgebung.melden(status) + } + + private fehler(id: string, text: string): RipStatus { + return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } + } + + /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten + * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: + * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, + * während Folge 2 heil daneben lag). */ + ueberspringen(id: string): void { + const vorgang = this.laufend.get(id) + if (vorgang === undefined || vorgang.titelAbbruch === undefined) return + vorgang.uebersprungen = true + vorgang.titelAbbruch.abort() + } + + /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ + async starten( + id: string, + auftrag: readonly RipAuftragTitel[] = [], + optionen: RipAuftragOptionen = {}, + ): Promise { + if (this.laufend.has(id)) { + this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) + return + } + + const programm = this.umgebung.makemkvcon() + if (programm.length === 0) { + this.umgebung.melden( + this.fehler( + id, + 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)', + ), + ) + return + } + + const geraet = this.umgebung.geraet(id) + if (geraet === null) { + this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`)) + return + } + // Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt + // einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der + // Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf + // nie zu „es geht nicht" werden. + if (geraet.status === 'empty') { + this.umgebung.melden( + this.fehler( + id, + `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`, + ), + ) + return + } + + const abbruch = new AbortController() + const startZeit = this.umgebung.jetzt?.() ?? new Date() + const vorgangsName = vorgangsNameFuer(geraet, startZeit) + + // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). + if (geraet.typ === 'cd') { + this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) + this.melden(this.laufend.get(id)!.status) + await this.audioCdRippen(id, geraet, startZeit, abbruch) + return + } + if (geraet.typ === 'unknown') { + this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) + this.melden(this.laufend.get(id)!.status) + await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) + return + } + + const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) + mkdirSync(rohOrdner, { recursive: true }) + const zuordnung = this.umgebung.zuordnung?.(id) ?? null + const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName + this.umgebung.vorgangBeginnt?.({ + id: vorgangsName, + laufwerk: id, + typ: geraet.typ, + titel: anzeigeTitel, + jahr: zuordnung?.jahr ?? null, + rohOrdner, + begonnen: startZeit.toISOString(), + }) + + // TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch + // 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau: + // * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt + // Trailer, Werbung und Alterskennzeichen. + // * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht + // mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der + // Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren. + const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all'] + // Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet — + // vorher sprang er je Titel zurück auf null. + const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1] + // Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je + // Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten. + const auftragJeDatei = new Map() + const alleDateien = new Set() + let lesefehlerGesamt = false + const gescheitert: string[] = [] + const uebersprungen: string[] = [] + const aufgeraeumt: string[] = [] + + this.laufend.set(id, { + abbruch, + status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName }, + }) + + const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => { + this.laufend.delete(id) + // Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0, + // Punkt „Aufräumen nach Abbruch"). + if (alleDateien.size === 0) { + try { + rmSync(rohOrdner, { recursive: true, force: true }) + } catch (fehler) { + text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})` + } + } + this.umgebung.vorgangEndet?.(vorgangsName, phase, text) + this.melden({ + id, + phase, + prozent: -1, + text, + dateien: [...alleDateien], + lesefehler: lesefehlerGesamt, + vorgangId: vorgangsName, + }) + } + + for (let i = 0; i < laeufe.length; i++) { + const welcher = laeufe[i] + // Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf, + // „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch. + const titelAbbruch = new AbortController() + const weiterreichen = (): void => titelAbbruch.abort() + abbruch.signal.addEventListener('abort', weiterreichen, { once: true }) + const vorgang = this.laufend.get(id) + if (vorgang !== undefined) { + vorgang.titelAbbruch = titelAbbruch + vorgang.uebersprungen = false + } + + const beschriftung = + welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})` + this.melden({ + id, + phase: 'rippt', + prozent: gewichteterFortschritt(gewichte, i, -1), + text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`, + dateien: [...alleDateien], + lesefehler: lesefehlerGesamt, + titelNr: i + 1, + titelGesamt: laeufe.length, + haengt: null, + vorgangId: vorgangsName, + }) + + // Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch + // oder Fehler ist eine halbe Datei und wird entfernt (5.4.0). + const vorher = dateienIn(rohOrdner) + const rip = await rippen({ + programm, + geraetepfad: geraet.pfad, + zielOrdner: rohOrdner, + titel: welcher, + abbruch: titelAbbruch.signal, + aufFortschritt: (prozent) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) }) + }, + aufMeldung: (_code, text) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, text }) + }, + aufHaengt: (klemmt) => { + const alt = this.laufend.get(id)?.status + if (alt !== undefined) this.melden({ ...alt, haengt: klemmt }) + }, + }) + abbruch.signal.removeEventListener('abort', weiterreichen) + + if (rip.status === 'cancelled') { + aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) + // Nur dieser Titel — oder der ganze Auftrag? + if (this.laufend.get(id)?.uebersprungen !== true) { + beenden( + 'abgebrochen', + 'Abgebrochen.' + + (aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') + + (alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''), + ) + return + } + uebersprungen.push(beschriftung) + continue + } + + lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler + if (rip.status === 'error') { + // EIN kaputter Titel beendet den Auftrag nicht — die anderen sind + // vielleicht heil. Der Grund wird gesammelt und am Ende genannt. + // Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig. + aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher)) + gescheitert.push(`${beschriftung}: ${rip.fehler}`) + continue + } + // rippen() liefert den ganzen Zielordner zurück, also auch die + // Dateien der vorigen Titel — neu ist, was noch nicht bekannt war. + const neu = rip.dateien.filter((d) => !alleDateien.has(d)) + for (const d of rip.dateien) alleDateien.add(d) + for (const d of neu) auftragJeDatei.set(d, auftrag[i]) + if (neu.length === 0) { + // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben + // (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert. + gescheitert.push(`${beschriftung}: keine Datei entstanden`) + } + } + + const nachLauf = this.laufend.get(id) + if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined + + if (alleDateien.size === 0) { + beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.') + return + } + + const hinweise: string[] = [] + if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`) + if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`) + if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`) + + // ── Übergabe an die Warteschlange (5.4.0) ────────────────────────── + const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => { + const a = auftragJeDatei.get(pfad) + const rolle: TitelRolle = a?.rolle ?? 'hauptfilm' + return { + pfad, + titelNr: a?.nr ?? -1, + rolle, + dauerS: a?.dauerS ?? 0, + groesseBytes: groesseVon(pfad), + audioSprachen: a?.audioSprachen ?? [], + untertitelSprachen: a?.untertitelSprachen ?? [], + } + }) + const angaben = this.umgebung.discAngaben?.(id) ?? null + const kompression: KompressionsAuftrag = { + vorgangId: vorgangsName, + laufwerk: id, + discTyp: geraet.typ, + rohOrdner, + dateien, + zuordnung, + staffel: optionen.staffel ?? angaben?.staffel ?? null, + ersteFolge: optionen.ersteFolge ?? null, + discNr: angaben?.discNr ?? null, + fingerprint: this.umgebung.fingerprint?.(id) ?? null, + lesefehler: lesefehlerGesamt, + hinweise, + begonnen: startZeit.toISOString(), + preset: '', + zielOrdner: '', + } + const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0 + const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0) + + // Das Laufwerk ist FREI — die Kompression läuft ohne es weiter. + this.laufend.delete(id) + this.umgebung.nachRipAuswerfen?.(id) + this.melden({ + id, + phase: 'fertig', + prozent: 100, + text: + `Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` + + (platz > 0 + ? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.` + : 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') + + (lesefehlerGesamt + ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' + : '') + + (hinweise.length > 0 ? ` ⚠ ${hinweise.join(' ')}` : ''), + dateien: dateien.map((d) => d.pfad), + lesefehler: lesefehlerGesamt, + vorgangId: vorgangsName, + }) + } + + abbrechen(id: string): void { + this.laufend.get(id)?.abbruch.abort() + } + + // ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ── + private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise { + const fertigMachen = (status: RipStatus): void => { + this.laufend.delete(id) + this.melden(status) + } + const flac = this.umgebung.flac() + if (flac.length === 0) { + fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).')) + return + } + if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) { + fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.')) + return + } + let spuren: Spur[] + try { + spuren = this.umgebung.audioToc(id).filter((s) => s.audio) + } catch (fehler) { + fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`)) + return + } + if (spuren.length === 0) { + fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?')) + return + } + + // MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne + // Treffer heißen die Dateien „Track 01" — das ist keine Ausrede, + // sondern die Wahrheit über eine Disc, die niemand kennt. + const kennung = kennungAusSpuren(spuren) + const antwort = await abfragen(kennung) + const tags = tagsJeSpur(antwort) + const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit) + const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName) + mkdirSync(ziel, { recursive: true }) + + const dateien: string[] = [] + for (let i = 0; i < spuren.length; i++) { + if (abbruch.signal.aborted) { + fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false }) + return + } + const spur = spuren[i] + const spurTags = tags.get(spur.nr) ?? {} + const name = dateiname(spur.nr, spurTags['TITLE'] ?? '') + const zielDatei = join(ziel, `${name}.flac`) + const stand = this.laufend.get(id)?.status + if (stand !== undefined) { + this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) }) + } + + // Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang + // hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht. + const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-')) + try { + const wav = join(arbeit, 'spur.wav') + const strom = createWriteStream(wav) + strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) + this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { + const s = this.laufend.get(id)?.status + if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) + }) + await new Promise((fertig, kaputt) => { + strom.end(() => fertig()) + strom.on('error', kaputt) + }) + const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags)) + if (flacErgebnis.code !== 0) { + fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`)) + return + } + } catch (fehler) { + fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`)) + return + } finally { + rmSync(arbeit, { recursive: true, force: true }) + } + dateien.push(zielDatei) + } + + const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) + if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { + let groesse = 0 + for (const datei of dateien) groesse += groesseVon(datei) + this.umgebung.bibliothekEintragen({ + fingerprint, + titel: ordnerName, + jahr: null, + typ: 'cd', + groesseBytes: groesse, + ablageort: ziel, + datum: startZeit.toISOString(), + dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), + posterPfad: '', + }) + } + fertigMachen({ + id, + phase: 'fertig', + prozent: 100, + text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''), + dateien, + lesefehler: false, + }) + } + + // ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ───────────────── + private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise { + const ziel = join(this.umgebung.ablage(), 'ISO') + mkdirSync(ziel, { recursive: true }) + const zielDatei = join(ziel, `${vorgangsName}.iso`) + const ergebnis = await isoSichern( + geraet.pfad, + zielDatei, + geraet.groesseBytes, + (prozent) => { + const stand = this.laufend.get(id)?.status + if (stand !== undefined) this.melden({ ...stand, prozent }) + }, + abbruch.signal, + ) + this.laufend.delete(id) + if (ergebnis.status === 'success') { + const fingerprint = this.umgebung.fingerprint?.(id) ?? null + if (fingerprint !== null) { + this.umgebung.bibliothekEintragen?.({ + fingerprint, + titel: vorgangsName, + jahr: null, + typ: 'iso', + groesseBytes: ergebnis.bytes, + ablageort: zielDatei, + datum: startZeit.toISOString(), + dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), + posterPfad: '', + }) + } + this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false }) + } else if (ergebnis.status === 'cancelled') { + this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) + } else { + this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false }) + } + } +} + +export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { + return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` +} + +function groesseVon(pfad: string): number { + try { + return statSync(pfad).size + } catch { + // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an + // der Liste selbst. + return 0 + } +} + +function dateienIn(ordner: string): Set { + try { + return new Set(readdirSync(ordner)) + } catch { + // Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und + // im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist. + return new Set() + } +} + +/** Entfernt, was seit `vorher` im Ordner dazukam — die halbe Datei eines + * abgebrochenen oder gescheiterten Titels. Liefert die Namen. */ +export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet): string[] { + const entfernt: string[] = [] + for (const name of dateienIn(ordner)) { + if (vorher.has(name)) continue + try { + unlinkSync(join(ordner, name)) + entfernt.push(name) + } catch (fehler) { + // Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da. + entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`) + } + } + return entfernt +} + +function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { + return new Promise((fertig) => { + const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + const stuecke: Buffer[] = [] + kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) + kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) + kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) + }) +} diff --git a/rippy-windows/src/kern/ablauf/warteschlange.ts b/rippy-windows/src/kern/ablauf/warteschlange.ts new file mode 100644 index 0000000..67ab7bf --- /dev/null +++ b/rippy-windows/src/kern/ablauf/warteschlange.ts @@ -0,0 +1,159 @@ +// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie +// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression +// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während +// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die +// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach — +// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und +// werden zusammen nicht schneller. +// +// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel +// prüft das Verhalten der Schlange auf Linux ohne HandBrake. +import type { KompressionStand } from '../../gemeinsam/nachrichten' +import type { KompressionsAuftrag } from './auftrag' +import type { AblageErgebnis, AblageMeldung } from './ablegen' + +export type Ausfuehren = ( + auftrag: KompressionsAuftrag, + melden: AblageMeldung, + abbruch: AbortSignal, +) => Promise + +interface Eintrag { + auftrag: KompressionsAuftrag + titel: string + abbruch: AbortController +} + +export interface WarteschlangenStand { + laeuft: KompressionStand | null + wartend: KompressionStand[] +} + +export class KompressionsWarteschlange { + private wartend: Eintrag[] = [] + private laufend: Eintrag | null = null + private laufendStand: KompressionStand | null = null + private arbeitet = false + + constructor( + private readonly ausfuehren: Ausfuehren, + /** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */ + private readonly beiStand: (stand: WarteschlangenStand) => void, + /** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */ + private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise, + ) {} + + /** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes). + * Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */ + einreihen(auftrag: KompressionsAuftrag, titel: string): number { + if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1 + const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId) + if (schon >= 0) return schon + (this.laufend === null ? 1 : 2) + this.wartend.push({ auftrag, titel, abbruch: new AbortController() }) + const platz = this.wartend.length + (this.laufend === null ? 0 : 1) + this.beiStand(this.stand()) + void this.schleife() + return platz + } + + /** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */ + abbrechen(vorgangId: string): boolean { + const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId) + if (i >= 0) { + const [weg] = this.wartend.splice(i, 1) + this.beiStand(this.stand()) + void this.beiErgebnis(weg.auftrag, { + status: 'abgebrochen', + zielOrdner: '', + dateien: [], + text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.', + komprimiert: false, + preset: '', + fertigBytes: 0, + hinweise: [], + }) + return true + } + if (this.laufend?.auftrag.vorgangId === vorgangId) { + this.laufend.abbruch.abort() + return true + } + return false + } + + stand(): WarteschlangenStand { + return { + laeuft: this.laufendStand === null ? null : { ...this.laufendStand }, + wartend: this.wartend.map((e) => ({ + vorgangId: e.auftrag.vorgangId, + titel: e.titel, + prozent: -1, + text: 'wartet', + phase: 'wartet', + })), + } + } + + laeuftEtwas(): boolean { + return this.laufend !== null || this.wartend.length > 0 + } + + /** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */ + kennt(vorgangId: string): boolean { + return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId) + } + + private async schleife(): Promise { + if (this.arbeitet) return + this.arbeitet = true + try { + while (this.wartend.length > 0) { + const eintrag = this.wartend.shift()! + this.laufend = eintrag + this.laufendStand = { + vorgangId: eintrag.auftrag.vorgangId, + titel: eintrag.titel, + prozent: -1, + text: 'Beginne …', + phase: 'komprimiert', + } + this.beiStand(this.stand()) + let ergebnis: AblageErgebnis + try { + ergebnis = await this.ausfuehren( + eintrag.auftrag, + (stand) => { + if (this.laufendStand === null) return + this.laufendStand = { ...this.laufendStand, ...stand } + this.beiStand(this.stand()) + }, + eintrag.abbruch.signal, + ) + } catch (fehler) { + // R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag, + // kein stilles Ende der Schlange. + ergebnis = { + status: 'fehler', + zielOrdner: '', + dateien: [], + text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`, + komprimiert: false, + preset: '', + fertigBytes: 0, + hinweise: [], + } + } + this.laufend = null + this.laufendStand = null + try { + await this.beiErgebnis(eintrag.auftrag, ergebnis) + } catch (fehler) { + console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`) + } + this.beiStand(this.stand()) + } + } finally { + this.arbeitet = false + } + } +} diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index ebb410a..8d6f9a8 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -3,19 +3,42 @@ // Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5), // Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort. // +// Seit 5.4.0 dazu: Vorgänge als Datensätze (welcher Roh-Ordner gehört zu +// welchem Film), die Kompressions-Warteschlange (das Laufwerk ist frei, +// sobald die Roh-Dateien liegen), das Roh-Aufräumen im Takt, die +// Wiederaufnahme nach einem Neustart, die Laufwerks-Prüfung aus dem +// Windows-Protokoll und die Nachprüfung der Erkennung nach dem Info-Lauf. +// // parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess« // und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen // Beweis beweise/sqlite-main.js. import type { MessagePortMain } from 'electron' +import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import { istFensterNachricht, istHauptNachricht, type DbStatus, + type DiscInfoStand, type KernNachricht, type MetaVorschlag, + type VorgaengeStand, + type VorgangEintrag, } from '../gemeinsam/nachrichten' +import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen' +import { auftragAusJson, auftragZuJson } from './ablauf/auftrag' import { Pipeline } from './ablauf/pipeline' +import { KompressionsWarteschlange } from './ablauf/warteschlange' +import { + AUFBEWAHRUNG_SCHLUESSEL, + aufraeumen, + faelligAm, + fremdeOrdner, + istUnterRohWurzel, + ordnerGroesse, + regelLesen, + regelText, +} from './ablage/rohaufraeumen' import type { LaufwerkApi } from './laufwerk/codes' import { audioSpurLesen, @@ -25,15 +48,17 @@ import { kennung, listOptischeLaufwerke, } from './laufwerk/disc' +import { beurteilen, ereignisseLesen } from './laufwerk/ereignisse' import { DiscWache } from './laufwerk/wache' import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale' import { OmdbClient } from './metadaten/omdb' import { TmdbClient } from './metadaten/tmdb' -import { zuordnen, type Zuordnung } from './metadaten/zuordnung' +import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from './metadaten/zuordnung' import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung' -import { Datenbank } from './speicher/db' +import { Datenbank, type Vorgang } from './speicher/db' import { titelInfoLesen, ABGELOEST } from './rip/makemkv' -import { titelVorauswahl } from './rip/titelwahl' +import { sprachenFuerTitel } from './rip/parser' +import { strukturHinweis, titelVorauswahl } from './rip/titelwahl' import { finden } from './werkzeuge/katalog' // --datenbank= kommt vom Haupt (der Kern kennt Electrons app.getPath @@ -72,6 +97,8 @@ if (pfad === null) { } } +const smokeKern = process.argv.includes('--smoke-kern') + let fensterPort: MessagePortMain | null = null function anFenster(nachricht: KernNachricht): void { @@ -83,6 +110,11 @@ function anHauptUndFenster(nachricht: KernNachricht): void { anFenster(nachricht) } +function hinweis(text: string): void { + console.log(`[kern] ${text}`) + anFenster({ art: 'kern-hinweis', text }) +} + // ── Laufwerke (W-1) ───────────────────────────────────────────────────── // koffi wird erst HIER geladen, über einen echten dynamischen import(): // Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein @@ -100,6 +132,39 @@ const discFingerprints = new Map() // das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. const discMerkmale = new Map() +/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster + * baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code, und jedes + * neue Feld hätte dreimal nachgezogen werden müssen. */ +function discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand { + const m = discMerkmale.get(id) + const meta = zuordnung?.meta + return { + id, + titel: zuordnung?.titel ?? '', + jahr: zuordnung?.jahr ?? null, + confidence: zuordnung?.confidence ?? 0, + quelle: quelle ?? meta?.quelle ?? '', + posterPfad: meta?.posterPfad ?? '', + backdropPfad: meta?.backdropPfad ?? '', + beschreibung: meta?.beschreibung ?? '', + // § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal + // durchgelaufen ist — und fragt nach, statt stumm zu doppeln. + schonGerippt: zuordnung === null ? null : (db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null), + discNr: m?.discNr ?? null, + staffel: m?.staffel ?? null, + folgen: m?.folgen ?? 0, + typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '', + logoPfad: meta?.logoPfad ?? '', + tagline: meta?.tagline ?? '', + bewertung: meta?.bewertung ?? 0, + fsk: meta?.fsk ?? '', + laufzeitMinuten: meta?.laufzeitMinuten ?? 0, + genres: meta?.genres ?? [], + besetzung: meta?.besetzung ?? [], + hinweise: zuordnung?.hinweise ?? [], + } +} + async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise { try { const gefunden = discTitel(geraetepfad) @@ -109,28 +174,14 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '') // Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) — - // trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. - const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie }) - discZuordnungen.set(id, zuordnung) - anHauptUndFenster({ - art: 'disc-info', - info: { - id, - titel: zuordnung.titel, - jahr: zuordnung.jahr, - confidence: zuordnung.confidence, - quelle: zuordnung.meta.quelle, - posterPfad: zuordnung.meta.posterPfad, - backdropPfad: zuordnung.meta.backdropPfad, - beschreibung: zuordnung.meta.beschreibung, - // § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal - // durchgelaufen ist — und fragt nach, statt stumm zu doppeln. - schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null, - discNr: gefunden.discNr, - staffel: gefunden.staffel, - folgen: gefunden.folgen, - }, + // trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede + // Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0). + const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { + istSerie: gefunden.istSerie, + alternativen: gefunden.alternativen, }) + discZuordnungen.set(id, zuordnung) + anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung) }) } catch (fehler) { // R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt // werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter). @@ -183,43 +234,27 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise 0 ? meta.titel : vorschlag.titel, + jahr: meta.jahr ?? vorschlag.jahr, // Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5). confidence: 1, - meta: { - typ: vorschlag.typ, - id: vorschlag.tmdbId, - titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel, - jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr, - beschreibung: details?.overview ?? '', - posterPfad: details?.poster_path ?? vorschlag.posterPfad, - backdropPfad: details?.backdrop_path ?? '', - laufzeitMinuten: details?.runtime ?? 0, - genres: (details?.genres ?? []).map((g) => g.name), - quelle: 'tmdb', - }, + meta, } discZuordnungen.set(id, zuordnung) - anHauptUndFenster({ - art: 'disc-info', - info: { - id, - titel: zuordnung.titel, - jahr: zuordnung.jahr, - confidence: 1, - quelle: 'deine Wahl', - posterPfad: zuordnung.meta.posterPfad, - backdropPfad: zuordnung.meta.backdropPfad, - beschreibung: zuordnung.meta.beschreibung, - schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null, - // Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt. - discNr: discMerkmale.get(id)?.discNr ?? null, - staffel: discMerkmale.get(id)?.staffel ?? null, - folgen: discMerkmale.get(id)?.folgen ?? 0, - }, - }) + anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung, 'deine Wahl') }) } catch (fehler) { anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` }) } @@ -243,23 +278,7 @@ const laufwerkeBereit: Promise = (async () => { discZuordnungen.delete(ereignis.geraet.id) discFingerprints.delete(ereignis.geraet.id) discMerkmale.delete(ereignis.geraet.id) - anHauptUndFenster({ - art: 'disc-info', - info: { - id: ereignis.geraet.id, - titel: '', - jahr: null, - confidence: 0, - quelle: '', - posterPfad: '', - backdropPfad: '', - beschreibung: '', - schonGerippt: null, - discNr: null, - staffel: null, - folgen: 0, - }, - }) + anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) }) } } }, @@ -277,9 +296,6 @@ const laufwerkeBereit: Promise = (async () => { } })() -// ── Rippen (W-2) ──────────────────────────────────────────────────────── -// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort -// nichts steht, gilt Videos\Rippy im Benutzerprofil. // ── Titel der Disc lesen (§ 6.2) ──────────────────────────────────────── // Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und // nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr @@ -343,12 +359,16 @@ async function titelLesen(id: string): Promise { stand: { id, laeuft: false, - titel: wahl.titel, + // Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie + // zum Anhaken an. + titel: wahl.titel.map((t) => ({ ...t, sprachen: sprachenFuerTitel(info.streams.get(t.nr)) })), einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: ohneErgebnis ? info.ursachen.join(' · ') : '', }, }) + // Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen. + if (info.titel.length > 0) await erkennungNachpruefen(id, wahl, info.titel) } catch (fehler) { // Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist // Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund — @@ -369,14 +389,59 @@ async function titelLesen(id: string): Promise { } } +/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung. + * Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb, + * Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird + * „bestätigt", oder Rippy merkt selbst, dass es danebenlag. */ +async function erkennungNachpruefen( + id: string, + wahl: ReturnType, + titel: Parameters[0], +): Promise { + const bisher = discZuordnungen.get(id) + if (bisher === undefined) return + const gewaehlt = wahl.titel.filter((t) => t.gewaehlt) + const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS) + const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0 + const messung = { + hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster), + folgenS: folgen, + struktur: strukturHinweis(titel), + } + let zuordnung = bisher + // Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat + // nicht selbst gewählt): einmal als Serie fragen — das ist der + // Fingerabdruck, der Serien auch ohne bdmt erkennt. + if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) { + const merkmale = discMerkmale.get(id) + if (merkmale !== undefined && merkmale.titel.length > 0) { + const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') + const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '') + const nochmal = await zuordnen(merkmale.titel, tmdb, omdb, { istSerie: true, alternativen: merkmale.alternativen }) + if (nochmal.meta.typ === 'tv') { + zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] } + } + } + } + const geprueft = nachpruefen(zuordnung, messung) + const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])] + const neu = { ...geprueft, hinweise } + discZuordnungen.set(id, neu) + anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, neu) }) +} + function ablageOrdner(): string { const eingestellt = db?.einstellungLesen('ablage') if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy') } +function rohWurzel(): string { + return join(ablageOrdner(), 'roh') +} + // Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN -// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults. +// Quelle (§ 6.7). const PIPELINE_EINSTELLUNGEN = [ 'transcodeEnabled', 'transcodePreset', @@ -404,6 +469,274 @@ function geraetVon(id: string): string | null { return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null } +// ── Vorgänge (5.4.0): Datensatz je Rip, Warteschlange, Aufräumen ──────── +function dateienAusJson(text: string): string[] { + const liste: string[] = [] + try { + const roh: unknown = JSON.parse(text) + if (Array.isArray(roh)) liste.push(...roh.map(String)) + } catch (fehler) { + // R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine + // fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst. + console.error(`[kern] fertige Dateien nicht lesbar: ${String(fehler)}`) + } + return liste +} + +const ablageUmgebung: AblageUmgebung = { + handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), + ablage: ablageOrdner, + einstellungen: einstellungenLaden, + presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets), + // Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2). + episodenLaufzeiten: async (tmdbId, staffel) => { + const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') + if (!tmdb.verfuegbar) return [] + const antwort = await tmdb.tvStaffel(tmdbId, staffel) + const paare: Array<[number, number]> = [] + for (const ep of antwort?.episodes ?? []) { + if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60]) + } + return paare + }, + bibliothekEintragen: (eintrag) => { + try { + db?.bibliothekEintragen(eintrag) + anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) + } catch (fehler) { + // R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber + // gesagt. + anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` }) + } + }, +} + +/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem + * Prozent-Tick. */ +const letztePhase = new Map() + +const warteschlange = new KompressionsWarteschlange( + (auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, ablageUmgebung, melden, abbruch), + (stand) => { + const l = stand.laeuft + if (l !== null && letztePhase.get(l.vorgangId) !== l.phase) { + letztePhase.set(l.vorgangId, l.phase) + db?.vorgangAktualisieren(l.vorgangId, { phase: l.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' }) + } + vorgaengeSenden() + }, + (auftrag, ergebnis) => { + letztePhase.delete(auftrag.vorgangId) + const jetzt = new Date().toISOString() + db?.vorgangAktualisieren(auftrag.vorgangId, { + phase: ergebnis.status, + beendet: jetzt, + zielOrdner: ergebnis.zielOrdner, + fertigBytes: ergebnis.fertigBytes, + komprimiert: ergebnis.komprimiert, + preset: ergebnis.preset, + fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text, + fertigeDateien: JSON.stringify(ergebnis.dateien), + }) + const vorgang = db?.vorgangLesen(auftrag.vorgangId) + const titel = vorgang?.titel ?? auftrag.vorgangId + const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '') + anHauptUndFenster({ + art: 'kompression-ergebnis', + vorgangId: auftrag.vorgangId, + titel, + ok: ergebnis.status === 'fertig', + text, + }) + // Regel „sofort": direkt nach der gelungenen Kompression aufräumen. + if (ergebnis.status === 'fertig') rohAufraeumenLauf() + else vorgaengeSenden() + }, +) + +function vorgaengeStand(): VorgaengeStand { + const regel = regelLesen(db?.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL)) + const liste = db?.vorgangListe() ?? [] + let rohGesamtBytes = 0 + const vorgaenge: VorgangEintrag[] = liste.map((v) => { + const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner) + const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0 + rohGesamtBytes += rohBytes + const auftrag = auftragAusJson(v.auftrag) + const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen' + return { + id: v.id, + laufwerk: v.laufwerk, + typ: v.typ, + titel: v.titel, + jahr: v.jahr, + phase: v.phase, + begonnen: v.begonnen, + beendet: v.beendet, + rohOrdner: v.rohOrdner, + rohBytes, + rohGeloescht: !rohDa, + zielOrdner: v.zielOrdner, + fertigBytes: v.fertigBytes, + komprimiert: v.komprimiert, + preset: v.preset, + fehler: v.fehler, + titelZahl: auftrag?.dateien.length ?? 0, + neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !warteschlange.kennt(v.id), + faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '', + } + }) + const bekannte = new Set(liste.map((v) => v.rohOrdner)) + const fremde = fremdeOrdner(rohWurzel(), bekannte) + for (const f of fremde) rohGesamtBytes += f.bytes + return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: warteschlange.stand() } +} + +function vorgaengeSenden(): void { + try { + anHauptUndFenster({ art: 'vorgaenge', stand: vorgaengeStand() }) + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `Vorgänge: ${String(fehler)}` }) + } +} + +function rohAufraeumenLauf(): void { + if (db === null) return + try { + const regel = regelLesen(db.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL)) + const kandidaten = db.vorgangListe().map((v) => ({ + id: v.id, + phase: v.phase, + komprimiert: v.komprimiert, + rohOrdner: v.rohOrdner, + rohGeloeschtAm: v.rohGeloeschtAm, + beendet: v.beendet, + fertigeDateien: dateienAusJson(v.fertigeDateien), + })) + const ergebnis = aufraeumen(kandidaten, regel, new Date()) + const jetzt = new Date().toISOString() + for (const id of ergebnis.geloescht) { + db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: 0 }) + hinweis(`Roh-Dateien von „${db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`) + } + for (const f of ergebnis.fehler) { + anFenster({ art: 'kern-fehler', text: `Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}` }) + } + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `Roh-Aufräumen: ${String(fehler)}` }) + } + vorgaengeSenden() +} + +function vorgangRohLoeschen(vorgangId: string): void { + const v = db?.vorgangLesen(vorgangId) + if (v === null || v === undefined) { + anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` }) + return + } + if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || warteschlange.kennt(vorgangId)) { + anFenster({ art: 'kern-fehler', text: `„${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.` }) + return + } + if (!istUnterRohWurzel(rohWurzel(), v.rohOrdner)) { + anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" liegt nicht unter ${rohWurzel()} — nicht angefasst.` }) + return + } + try { + rmSync(v.rohOrdner, { recursive: true, force: true }) + db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString(), rohBytes: 0 }) + hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`) + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}` }) + } + vorgaengeSenden() +} + +function fremdenOrdnerLoeschen(ordner: string): void { + if (!istUnterRohWurzel(rohWurzel(), ordner)) { + anFenster({ art: 'kern-fehler', text: `„${ordner}" liegt nicht direkt unter ${rohWurzel()} — nicht angefasst.` }) + return + } + const bekannt = (db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase()) + if (bekannt) { + anFenster({ art: 'kern-fehler', text: `„${ordner}" gehört zu einem Vorgang — dort löschen.` }) + return + } + try { + rmSync(ordner, { recursive: true, force: true }) + hinweis(`Ordner „${ordner}" gelöscht (von Hand).`) + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `„${ordner}" nicht gelöscht: ${String(fehler)}` }) + } + vorgaengeSenden() +} + +/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */ +function vorgangNeuKomprimieren(vorgangId: string, preset: string): void { + const v = db?.vorgangLesen(vorgangId) + if (v === null || v === undefined) { + anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` }) + return + } + if (warteschlange.kennt(vorgangId)) { + anFenster({ art: 'kern-fehler', text: `„${v.titel}" ist schon in der Warteschlange.` }) + return + } + const auftrag = auftragAusJson(v.auftrag) + if (auftrag === null) { + anFenster({ art: 'kern-fehler', text: `„${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.` }) + return + } + const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad)) + if (vorhandene.length === 0) { + anFenster({ art: 'kern-fehler', text: `„${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).` }) + return + } + if (vorhandene.length < auftrag.dateien.length) { + hinweis(`„${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`) + } + const neu = { + ...auftrag, + dateien: vorhandene, + preset, + // Die neue Fassung ersetzt die alte im selben Ordner. + zielOrdner: v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) ? v.zielOrdner : '', + } + db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) }) + const platz = warteschlange.einreihen(neu, v.titel) + hinweis(`„${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`) + vorgaengeSenden() +} + +/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */ +function vorgaengeWiederaufnehmen(): void { + if (db === null) return + const jetzt = new Date().toISOString() + for (const v of db.vorgangListe()) { + if (v.phase === 'rippt') { + db.vorgangAktualisieren(v.id, { + phase: 'abgebrochen', + beendet: jetzt, + fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.', + }) + continue + } + if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') { + const auftrag = auftragAusJson(v.auftrag) + if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) { + warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel) + hinweis(`„${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`) + } else { + db.vorgangAktualisieren(v.id, { + phase: 'fehler', + beendet: jetzt, + fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.', + }) + } + } + } +} + const ripVerwaltung = new Pipeline({ makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), @@ -425,37 +758,93 @@ const ripVerwaltung = new Pipeline({ }, ablage: ablageOrdner, einstellungen: einstellungenLaden, - presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets), - melden: (status) => anHauptUndFenster({ art: 'rip-status', status }), + melden: (status) => { + anHauptUndFenster({ art: 'rip-status', status }) + // Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows- + // Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8) + if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerkPruefen(status.id) + }, zuordnung: (id) => discZuordnungen.get(id) ?? null, discAngaben: (id) => { const m = discMerkmale.get(id) return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr } }, - // Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2). - episodenLaufzeiten: async (tmdbId, staffel) => { - const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') - if (!tmdb.verfuegbar) return [] - const antwort = await tmdb.tvStaffel(tmdbId, staffel) - const paare: Array<[number, number]> = [] - for (const ep of antwort?.episodes ?? []) { - if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60]) - } - return paare - }, fingerprint: (id) => discFingerprints.get(id) ?? null, - bibliothekEintragen: (eintrag) => { - try { - db?.bibliothekEintragen(eintrag) - anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) - } catch (fehler) { - // R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber - // gesagt. - anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` }) + bibliothekEintragen: ablageUmgebung.bibliothekEintragen, + vorgangBeginnt: (v) => { + const vorgang: Vorgang = { + id: v.id, + laufwerk: v.laufwerk, + typ: v.typ, + titel: v.titel, + jahr: v.jahr, + phase: 'rippt', + begonnen: v.begonnen, + beendet: null, + rohOrdner: v.rohOrdner, + rohBytes: 0, + rohGeloeschtAm: null, + zielOrdner: '', + fertigBytes: 0, + komprimiert: false, + preset: '', + fehler: '', + auftrag: '{}', + lesefehler: false, + fertigeDateien: '[]', } + db?.vorgangAnlegen(vorgang) + vorgaengeSenden() + }, + vorgangEinreihen: (auftrag, titel) => { + db?.vorgangAktualisieren(auftrag.vorgangId, { + phase: 'wartet', + titel, + jahr: auftrag.zuordnung?.jahr ?? null, + rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0), + lesefehler: auftrag.lesefehler, + auftrag: auftragZuJson(auftrag), + }) + const platz = warteschlange.einreihen(auftrag, titel) + vorgaengeSenden() + return platz + }, + vorgangEndet: (id, phase, text) => { + db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text }) + vorgaengeSenden() + }, + nachRipAuswerfen: (id) => { + // Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk. + if (db?.einstellungLesen('autoAuswurf') === 'false') return + void auswerfen(id) }, }) +// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────── +const GESUNDHEIT_STUNDEN = 24 + +async function laufwerkPruefen(id: string): Promise { + if (process.platform !== 'win32') return + const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN) + if ('fehler' in ergebnis) { + anFenster({ + art: 'laufwerk-gesundheit', + id, + bericht: { + urteil: 'nicht-lesbar', + text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`, + fehlerhafteBloecke: 0, + controllerfehler: 0, + sonstige: 0, + letzte: [], + stunden: GESUNDHEIT_STUNDEN, + }, + }) + return + } + anFenster({ art: 'laufwerk-gesundheit', id, bericht: beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN) }) +} + // ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den // Kern; erlaubt sind genau diese Schlüssel ─────────────────────────── const ERLAUBTE_EINSTELLUNGEN = new Set([ @@ -479,6 +868,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([ 'werkzeug.makemkv', 'werkzeug.handbrake', 'werkzeug.flac', + AUFBEWAHRUNG_SCHLUESSEL, ]) function einstellungenSenden(): void { @@ -515,6 +905,25 @@ async function schluesselLauf(): Promise { // Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten. setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref() +/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen + * Sprachen in der Roh-Datei, die der Dialog anbot. */ +async function spurauswahlLauf(): Promise { + if (process.platform !== 'win32') return + try { + const { spurauswahlSicherstellen } = await import('./werkzeuge/spurauswahl') + const stand = spurauswahlSicherstellen() + if (stand.stand === 'eigen' || stand.stand === 'fehler') { + anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${stand.text}` }) + } else if (stand.stand === 'gesetzt') { + hinweis(stand.text) + } else { + console.log(`[kern] ${stand.text}`) + } + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${String(fehler)}` }) + } +} + // ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ──────────── // Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt // (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der @@ -702,7 +1111,7 @@ process.parentPort.on('message', (ereignis) => { } else if (frage.art === 'titel-lesen') { void titelLesen(frage.id) } else if (frage.art === 'rip-start') { - void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [])) + void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [], frage.optionen ?? {})) } else if (frage.art === 'rip-abbruch') { ripVerwaltung.abbrechen(frage.id) } else if (frage.art === 'rip-ueberspringen') { @@ -719,6 +1128,8 @@ process.parentPort.on('message', (ereignis) => { } else { db.einstellungSchreiben(frage.schluessel, frage.wert) einstellungenSenden() + // Eine neue Aufbewahrungs-Regel wirkt sofort auf die Anzeige. + if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaengeSenden() } } else if (frage.art === 'einstellungen-laden') { einstellungenSenden() @@ -730,6 +1141,22 @@ process.parentPort.on('message', (ereignis) => { void makemkvUpdateLauf() } else if (frage.art === 'schluessel-pruefen') { void schluesselLauf().then(() => werkzeugeSenden()) + } else if (frage.art === 'vorgaenge-laden') { + vorgaengeSenden() + } else if (frage.art === 'vorgang-roh-loeschen') { + vorgangRohLoeschen(frage.vorgangId) + } else if (frage.art === 'vorgang-neu-komprimieren') { + vorgangNeuKomprimieren(frage.vorgangId, frage.preset ?? '') + } else if (frage.art === 'kompression-abbrechen') { + if (!warteschlange.abbrechen(frage.vorgangId)) { + anFenster({ art: 'kern-fehler', text: `Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.` }) + } + } else if (frage.art === 'roh-aufraeumen') { + rohAufraeumenLauf() + } else if (frage.art === 'fremden-ordner-loeschen') { + fremdenOrdnerLoeschen(frage.ordner) + } else if (frage.art === 'laufwerk-pruefen') { + void laufwerkPruefen(frage.id) } }) fensterPort.start() @@ -744,6 +1171,7 @@ process.parentPort.on('message', (ereignis) => { anFenster({ art: 'schluessel-status', stand: schluesselStand }) } }) + vorgaengeSenden() } }) @@ -759,11 +1187,20 @@ function bereitNachricht(): KernNachricht { process.parentPort.postMessage(bereitNachricht()) // Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster. einstellungenSenden() +// Was nach dem letzten Beenden noch offen war (5.4.0). +vorgaengeWiederaufnehmen() // Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an — // der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein. -if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { +if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { void schluesselLauf() + void spurauswahlLauf() // Werkzeuge einmal beim Start messen — die Preset-Empfehlung der // Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster. void werkzeugeSenden() } +// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und +// nach jeder gelungenen Kompression (siehe Warteschlange). +if (!smokeKern) { + setTimeout(() => rohAufraeumenLauf(), 60_000).unref() + setInterval(() => rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref() +} diff --git a/rippy-windows/src/kern/komprimieren/handbrake.ts b/rippy-windows/src/kern/komprimieren/handbrake.ts index 52bf1d0..5e6dc0a 100644 --- a/rippy-windows/src/kern/komprimieren/handbrake.ts +++ b/rippy-windows/src/kern/komprimieren/handbrake.ts @@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen { preset: string audioSprachen?: readonly string[] untertitelSprachen?: readonly string[] + /** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0): + * japanischer Ton, deutsche Untertitel sollen von selbst an sein. */ + untertitelStandard?: boolean } +// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026, +// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per +// ffprobe geprüft — Regel D): +// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren) +// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine) +// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3) +// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN- +// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge +// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und +// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt +// sie damit von selbst) +// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik) export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] { const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset] const format = FORMATE[extname(optionen.ausgabe).toLowerCase()] @@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] { const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0) if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(',')) args.push('--all-subtitles') + // Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die + // Liste bestimmt die Reihenfolge (gemessen, siehe oben). + if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1') return args } @@ -224,6 +242,7 @@ export interface HbLaufOptionen { preset: string audioSprachen?: readonly string[] untertitelSprachen?: readonly string[] + untertitelStandard?: boolean /** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */ extraArgs?: readonly string[] aufFortschritt?: (prozent: number) => void diff --git a/rippy-windows/src/kern/komprimieren/scan.ts b/rippy-windows/src/kern/komprimieren/scan.ts new file mode 100644 index 0000000..164f0c9 --- /dev/null +++ b/rippy-windows/src/kern/komprimieren/scan.ts @@ -0,0 +1,118 @@ +// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken +// WIRKLICH in der Roh-Datei? (5.4.0) +// +// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV +// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen, +// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor +// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine +// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer +// Wunsch still durchrutscht. +// +// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026, +// Regel D): `HandBrakeCLI --scan --json -i -t 1` schreibt ein +// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem +// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das +// Objekt endet also NICHT am Textende — es wird über die Klammern +// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2, +// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}. +import { spawn } from 'node:child_process' +import { textVon } from '../rip/parser' + +export interface HbSpuren { + audio: Array<{ nr: number; lang: string; beschreibung: string }> + untertitel: Array<{ nr: number; lang: string; beschreibung: string }> + dauerS: number +} + +export function buildScanArgs(datei: string): string[] { + return ['--scan', '--json', '--input', datei, '--title', '1'] +} + +/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */ +export function spurenAusScan(ausgabe: string): HbSpuren | null { + const marke = ausgabe.indexOf('JSON Title Set:') + if (marke < 0) return null + const start = ausgabe.indexOf('{', marke) + if (start < 0) return null + let tiefe = 0 + let ende = -1 + for (let i = start; i < ausgabe.length; i++) { + const zeichen = ausgabe[i] + if (zeichen === '{') tiefe += 1 + else if (zeichen === '}') { + tiefe -= 1 + if (tiefe === 0) { + ende = i + break + } + } + } + if (ende < 0) return null + let daten: unknown + try { + daten = JSON.parse(ausgabe.slice(start, ende + 1)) + } catch { + // Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein + // erfundener leerer Befund (R2). + return null + } + if (typeof daten !== 'object' || daten === null) return null + const liste = (daten as { TitleList?: unknown }).TitleList + if (!Array.isArray(liste) || liste.length === 0) return null + const titel = liste[0] as Record + const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => { + if (!Array.isArray(feld)) return [] + return feld.map((s, i) => { + const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record + return { + nr: i + 1, + lang: String(eintrag.LanguageCode ?? '').toLowerCase(), + beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''), + } + }) + } + const dauer = (titel.Duration ?? {}) as Record + const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + return { + audio: spuren(titel.AudioList), + untertitel: spuren(titel.SubtitleList), + dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds), + } +} + +/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */ +export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise { + return new Promise((fertig) => { + if (programm.length === 0) { + fertig(null) + return + } + const kind = spawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + const stuecke: Buffer[] = [] + kind.stdout.on('data', (s: Buffer) => stuecke.push(s)) + kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) + const zeiger = setTimeout(() => kind.kill(), timeoutMs) + kind.on('error', () => { + clearTimeout(zeiger) + fertig(null) + }) + kind.on('close', () => { + clearTimeout(zeiger) + fertig(spurenAusScan(textVon(Buffer.concat(stuecke)))) + }) + }) +} + +/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */ +export function fehlendeSprachen( + spuren: HbSpuren, + gewuenscht: { audio: readonly string[]; untertitel: readonly string[] }, +): { audio: string[]; untertitel: string[] } { + const da = (liste: Array<{ lang: string }>): Set => new Set(liste.map((s) => s.lang)) + const audioDa = da(spuren.audio) + const untertitelDa = da(spuren.untertitel) + return { + audio: gewuenscht.audio.filter((l) => !audioDa.has(l)), + untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)), + } +} diff --git a/rippy-windows/src/kern/laufwerk/ereignisse.ts b/rippy-windows/src/kern/laufwerk/ereignisse.ts new file mode 100644 index 0000000..954e1a3 --- /dev/null +++ b/rippy-windows/src/kern/laufwerk/ereignisse.ts @@ -0,0 +1,172 @@ +// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0). +// +// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand +// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die +// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy +// sie selbst. +// +// GEMESSEN (01.09.2026, Regel D, an diesem PC): +// wevtutil qe System "/q:" /f:xml /c:200 /rd:true +// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0." +// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden." +// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei) +// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um +// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17). +// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE +// Zeilenumbruch hintereinander; ein Regex je reicht. +// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit +// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der +// Pfad-Konverter, über spawn nicht). +import { spawn } from 'node:child_process' +import type { LaufwerkBericht } from '../../gemeinsam/nachrichten' +import { textVon } from '../rip/parser' + +export interface LaufwerkEreignis { + /** ISO-Zeit (UTC). */ + zeit: string + provider: string + id: number + geraet: string +} + +/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */ +export const EREIGNIS_TEXTE: Record = { + 7: 'fehlerhafter Block', + 11: 'Controllerfehler', + 15: 'Gerät nicht bereit', + 51: 'E/A-Fehler beim Lesen', + 153: 'E/A-Vorgang wiederholt', +} + +export function xpathFuer(stunden: number): string { + const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000 + return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]` +} + +export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] { + return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true'] +} + +/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */ +export function ereignisseAusXml(xml: string): LaufwerkEreignis[] { + const liste: LaufwerkEreignis[] = [] + for (const treffer of xml.matchAll(//g)) { + const block = treffer[0] + const provider = /]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '') + const zeit = /([^<]*)<\/Data>/.exec(block)?.[1] ?? '' + if (!Number.isInteger(id) || provider.length === 0) continue + liste.push({ zeit, provider, id, geraet }) + } + return liste +} + +/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein + * (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */ +export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht { + const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime()) + let fehlerhafteBloecke = 0 + let controllerfehler = 0 + let sonstige = 0 + for (const e of relevant) { + if (e.id === 7) fehlerhafteBloecke += 1 + else if (e.id === 11 || e.id === 15) controllerfehler += 1 + else sonstige += 1 + } + const letzte = relevant.slice(0, 8).map((e) => { + const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null + const zeit = + lokal !== null && !Number.isNaN(lokal.getTime()) + ? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}` + : '?' + return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})` + }) + const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC` + + if (relevant.length === 0) { + return { + urteil: 'ruhig', + text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`, + fehlerhafteBloecke, + controllerfehler, + sonstige, + letzte, + stunden, + } + } + if (controllerfehler > 0) { + return { + urteil: 'hardware', + text: + `${controllerfehler} Controllerfehler ${zeitraum}` + + (fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') + + '. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' + + 'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.', + fehlerhafteBloecke, + controllerfehler, + sonstige, + letzte, + stunden, + } + } + if (fehlerhafteBloecke > 0) { + return { + urteil: 'disc', + text: + `${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` + + 'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' + + 'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.', + fehlerhafteBloecke, + controllerfehler, + sonstige, + letzte, + stunden, + } + } + return { + urteil: 'unklar', + text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`, + fehlerhafteBloecke, + controllerfehler, + sonstige, + letzte, + stunden, + } +} + +/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */ +export function ereignisseLesen( + stunden: number, + timeoutMs = 30_000, +): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> { + return new Promise((fertig) => { + let kind: ReturnType + try { + kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + } catch (fehler) { + fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` }) + return + } + const stuecke: Buffer[] = [] + const fehlerStuecke: Buffer[] = [] + kind.stdout?.on('data', (s: Buffer) => stuecke.push(s)) + kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s)) + const zeiger = setTimeout(() => kind.kill(), timeoutMs) + kind.on('error', (fehler) => { + clearTimeout(zeiger) + fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` }) + }) + kind.on('close', (code) => { + clearTimeout(zeiger) + const xml = textVon(Buffer.concat(stuecke)) + // Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler + // steht auf stderr mit Code ≠ 0. + if (code !== 0 && xml.trim().length === 0) { + fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` }) + return + } + fertig({ ereignisse: ereignisseAusXml(xml) }) + }) + }) +} diff --git a/rippy-windows/src/kern/metadaten/discmerkmale.ts b/rippy-windows/src/kern/metadaten/discmerkmale.ts index 707f54b..bd563e4 100644 --- a/rippy-windows/src/kern/metadaten/discmerkmale.ts +++ b/rippy-windows/src/kern/metadaten/discmerkmale.ts @@ -121,14 +121,30 @@ export interface BdmtInhalt { titel: string /** — was die Disc trägt: Folgen, Tonspuren, Trailer. */ eintraege: string[] + /** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien + * (bdmt_deu neben bdmt_eng, 5.4.0) — ohne den Haupttitel, ohne Doppel. */ + alternativen: string[] } -/** Liest die bdmt-Datei EINMAL — bdmt_eng bevorzugt. Regex statt - * XML-Parser (Namespaces variieren je Authoring-Werkzeug). */ +/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen + * (pur, testbar). Regex statt XML-Parser (Namespaces variieren je + * Authoring-Werkzeug). */ +export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } { + const name = /([^<]{2,120})<\/di:name>/.exec(inhalt) + const eintraege: string[] = [] + for (const t of inhalt.matchAll(/]*>([^<]{1,120})<\/di:titleName>/g)) { + const text = t[1].trim() + if (text.length > 0) eintraege.push(text) + } + return { titel: name === null ? '' : name[1].trim(), eintraege } +} + +/** Liest die bdmt-Dateien — bdmt_eng zuerst, die anderen Sprachen liefern + * Titel-Varianten (5.4.0). */ export function inhaltAusBdmt(wurzel: string): BdmtInhalt { // Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine // Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer. - const leer: BdmtInhalt = { titel: '', eintraege: [] } + const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] } const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL') if (!existsSync(metaOrdner)) return leer let kandidaten: string[] @@ -137,23 +153,38 @@ export function inhaltAusBdmt(wurzel: string): BdmtInhalt { } catch { return leer } - const bdmt = - kandidaten.find((k) => k === 'bdmt_eng.xml') ?? - kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml')) - if (bdmt === undefined) return leer - let inhalt: string - try { - inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8') - } catch { - return leer + const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml')) + if (bdmts.length === 0) return leer + // Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann + // Deutsch, dann der Rest in Dateireihenfolge. + const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2) + bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b)) + let titel = '' + let eintraege: string[] = [] + const alternativen: string[] = [] + for (const bdmt of bdmts) { + let inhalt: string + try { + inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8') + } catch { + continue + } + const zerlegt = bdmtZerlegen(inhalt) + if (titel.length === 0 && zerlegt.titel.length > 0) { + titel = zerlegt.titel + eintraege = zerlegt.eintraege + continue + } + if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege + if ( + zerlegt.titel.length > 0 && + zerlegt.titel.toLowerCase() !== titel.toLowerCase() && + !alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase()) + ) { + alternativen.push(zerlegt.titel) + } } - const name = /([^<]{2,120})<\/di:name>/.exec(inhalt) - const eintraege: string[] = [] - for (const t of inhalt.matchAll(/]*>([^<]{1,120})<\/di:titleName>/g)) { - const text = t[1].trim() - if (text.length > 0) eintraege.push(text) - } - return { titel: name === null ? '' : name[1].trim(), eintraege } + return { titel, eintraege, alternativen } } /** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */ @@ -240,13 +271,15 @@ export interface DiscTitel { /** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es * keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */ istSerie: boolean + /** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */ + alternativen: string[] } /** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als * bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */ export const FOLGEN_FUER_SERIE = 2 -function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel { +function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel { const zusatz = discZusatzAbtrennen(titel) return { titel, @@ -255,6 +288,7 @@ function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): Dis staffel: zusatz.staffel, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, + alternativen, } } @@ -266,11 +300,11 @@ export function discTitel(geraetepfad: string): DiscTitel { if (wurzel.length > 0) { const bdmt = inhaltAusBdmt(wurzel) folgen = folgenZahl(bdmt.eintraege) - if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen) + if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen) } const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen) - return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE } + return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] } } /** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der diff --git a/rippy-windows/src/kern/metadaten/tmdb.ts b/rippy-windows/src/kern/metadaten/tmdb.ts index 44c3841..7cbcdf4 100644 --- a/rippy-windows/src/kern/metadaten/tmdb.ts +++ b/rippy-windows/src/kern/metadaten/tmdb.ts @@ -2,14 +2,50 @@ // ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…', // Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur // eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE. +// +// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per +// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen +// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D): +// movie/149?append_to_response=images,credits,release_dates +// &include_image_language=de,en,null +// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124, +// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null, +// file_path:'/….png'|'.svg', width, height, vote_average}], +// credits.cast[{name, character, profile_path, order}], +// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}] +// tv/46296?append_to_response=images,credits,content_ratings +// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}], +// seasons[{season_number, name, episode_count}], number_of_episodes +// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632 +// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei +// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der +// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296). export interface FilmTreffer { id: number title?: string name?: string - poster_path?: string + poster_path?: string | null + backdrop_path?: string | null release_date?: string first_air_date?: string + overview?: string + popularity?: number +} + +export interface TmdbLogo { + iso_639_1: string | null + file_path: string + width?: number + height?: number + vote_average?: number +} + +export interface TmdbCast { + name: string + character?: string + profile_path?: string | null + order?: number } export interface FilmDetails { @@ -19,10 +55,22 @@ export interface FilmDetails { release_date?: string first_air_date?: string overview?: string - poster_path?: string - backdrop_path?: string + poster_path?: string | null + backdrop_path?: string | null runtime?: number genres?: Array<{ name: string }> + tagline?: string + vote_average?: number + vote_count?: number + popularity?: number + episode_run_time?: number[] + number_of_episodes?: number + number_of_seasons?: number + seasons?: Array<{ season_number: number; name?: string; episode_count?: number }> + images?: { logos?: TmdbLogo[] } + credits?: { cast?: TmdbCast[] } + release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> } + content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> } } export function istV4Token(key: string): boolean { @@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean { const BASIS = 'https://api.themoviedb.org/3' +/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das + * bestbewertete. '' wenn keins. */ +export function logoAus(logos: readonly TmdbLogo[] | undefined): string { + if (logos === undefined || logos.length === 0) return '' + const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3) + const sortiert = [...logos] + .filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0) + .sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0)) + return sortiert[0]?.file_path ?? '' +} + +/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor + * Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */ +export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string { + if (typ === 'tv') { + const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE') + return (de?.rating ?? '').trim() + } + const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE') + const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0) + if (eintraege.length === 0) return '' + const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4) + return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim() +} + +/** Die ersten Darsteller mit Bild (pur). */ +export function besetzungAus( + cast: readonly TmdbCast[] | undefined, + hoechstens = 6, +): Array<{ name: string; rolle: string; bildPfad: string }> { + if (cast === undefined) return [] + return [...cast] + .filter((c) => typeof c.name === 'string' && c.name.length > 0) + .sort((a, b) => (a.order ?? 999) - (b.order ?? 999)) + .slice(0, hoechstens) + .map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' })) +} + export class TmdbClient { constructor( private readonly apiKey: string, @@ -84,11 +170,19 @@ export class TmdbClient { } async movieDetails(id: number): Promise { - return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null + return (await this.anfrage(`movie/${id}`, { + language: 'de-DE', + append_to_response: 'images,credits,release_dates', + include_image_language: 'de,en,null', + })) as FilmDetails | null } async tvDetails(id: number): Promise { - return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null + return (await this.anfrage(`tv/${id}`, { + language: 'de-DE', + append_to_response: 'images,credits,content_ratings', + include_image_language: 'de,en,null', + })) as FilmDetails | null } /** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */ diff --git a/rippy-windows/src/kern/metadaten/zuordnung.ts b/rippy-windows/src/kern/metadaten/zuordnung.ts index 544911f..3a643a4 100644 --- a/rippy-windows/src/kern/metadaten/zuordnung.ts +++ b/rippy-windows/src/kern/metadaten/zuordnung.ts @@ -12,9 +12,24 @@ // // Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira // (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5). +// +// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt: +// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren +// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge, +// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 % +// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen +// nicht mehr als Treffer. +// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts +// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich" +// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer +// auf einen Vorschlag zurück. +// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) — +// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage +// gemessen, nicht an der englischen. import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale' -import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb' +import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb' import type { OmdbClient, OmdbTreffer } from './omdb' +import type { Darsteller } from '../../gemeinsam/nachrichten' export const WENIGE_TREFFER = 3 @@ -30,6 +45,14 @@ export interface MetaTreffer { laufzeitMinuten: number genres: string[] quelle: 'tmdb' | 'omdb' | '' + // ── Kino-Banner v2 (5.4.0) ── + logoPfad: string + tagline: string + bewertung: number + fsk: string + besetzung: Darsteller[] + /** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */ + folgenGesamt: number } export interface Zuordnung { @@ -37,6 +60,8 @@ export interface Zuordnung { jahr: number | null confidence: number meta: MetaTreffer + /** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */ + hinweise?: string[] } function jahrAus(datum: string | undefined): number | null { @@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null { return Number.isFinite(jahr) && jahr > 0 ? jahr : null } -function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer { +export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer { + const laufzeit = + typ === 'movie' + ? (details.runtime ?? 0) + : ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0) return { typ, id, @@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta beschreibung: details.overview ?? '', posterPfad: details.poster_path ?? '', backdropPfad: details.backdrop_path ?? '', - laufzeitMinuten: details.runtime ?? 0, + laufzeitMinuten: laufzeit, genres: (details.genres ?? []).map((g) => g.name), quelle: 'tmdb', + logoPfad: logoAus(details.images?.logos), + tagline: (details.tagline ?? '').trim(), + bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0, + fsk: fskAus(details, typ), + besetzung: besetzungAus(details.credits?.cast), + folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0, } } @@ -73,27 +108,51 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer { laufzeitMinuten: treffer.runtime, genres: treffer.genres, quelle: 'omdb', + logoPfad: '', + tagline: '', + bewertung: 0, + fsk: '', + besetzung: [], + folgenGesamt: 0, } } +export const LEERER_TREFFER: MetaTreffer = { + typ: 'unknown', + id: null, + titel: '', + jahr: null, + beschreibung: '', + posterPfad: '', + backdropPfad: '', + laufzeitMinuten: 0, + genres: [], + quelle: '', + logoPfad: '', + tagline: '', + bewertung: 0, + fsk: '', + besetzung: [], + folgenGesamt: 0, +} + const UNBEKANNT = (titel: string): Zuordnung => ({ titel, jahr: null, confidence: 0.3, - meta: { - typ: 'unknown', - id: null, - titel, - jahr: null, - beschreibung: '', - posterPfad: '', - backdropPfad: '', - laufzeitMinuten: 0, - genres: [], - quelle: '', - }, + meta: { ...LEERER_TREFFER, titel }, }) +/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei + * „Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung, + * keine Bewertung — TMDb kennt dort nur den Namen. */ +export function istStub(details: FilmDetails): boolean { + const beschreibung = (details.overview ?? '').trim().length > 0 + const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null + const bewertet = (details.vote_count ?? 0) > 0 + return !beschreibung && !bild && !bewertet +} + /** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung, * Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */ function worte(t: string): Set { @@ -163,6 +222,35 @@ export interface DiscHinweis { * trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige * aus: Was bewiesen ist, muss nicht mehr geraten werden. */ istSerie?: boolean + /** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */ + alternativen?: readonly string[] +} + +interface Kandidat { + text: string + /** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */ + massstab: string +} + +/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die + * gestutzten — je Variante an ihrer eigenen Vorlage gemessen (pur). */ +export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] { + const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0) + const gesehen = new Set() + const voll: Kandidat[] = [] + const rest: Kandidat[] = [] + for (const quelle of quellen) { + const massstab = discZusatzAbtrennen(quelle).titel || quelle + const varianten = titelKandidaten(quelle) + if (varianten.length === 0) varianten.push(quelle) + varianten.forEach((text, i) => { + const schluessel = text.toLowerCase() + if (gesehen.has(schluessel)) return + gesehen.add(schluessel) + ;(i === 0 ? voll : rest).push({ text, massstab }) + }) + } + return [...voll, ...rest] } /** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist. @@ -183,6 +271,8 @@ export interface DiscHinweis { * '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer * 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat * Nummer 1 und fiel durch dieses Raster. + * * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer — der + * nächste wörtliche Treffer bekommt die Chance. */ export async function zuordnen( discTitel: string, @@ -194,43 +284,39 @@ export async function zuordnen( if (titel.length === 0) return UNBEKANNT('') const nurSerie = hinweis.istSerie === true - // Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz: - // '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden. - const massstab = discZusatzAbtrennen(titel).titel || titel - const kandidaten = titelKandidaten(titel) - if (kandidaten.length === 0) kandidaten.push(titel) + const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? []) let ersteFilme: FilmTreffer[] = [] let ersteSerien: FilmTreffer[] = [] for (const kandidat of kandidaten) { - const deckung = abdeckung(massstab, kandidat) + const deckung = abdeckung(kandidat.massstab, kandidat.text) const vollstaendig = deckung >= 1 /** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */ const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG)) - const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? []) + const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? []) if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme // Wörtlicher Filmtreffer (0,95) for (const film of filme) { - if (gleichBedeutend(film.title ?? '', kandidat)) { + if (gleichBedeutend(film.title ?? '', kandidat.text)) { const details = await tmdb.movieDetails(film.id) - if (details !== null) { + if (details !== null && !istStub(details)) { const meta = ausDetails('movie', film.id, details) return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta } } } } - const serien = (await tmdb.searchTv(kandidat)) ?? [] + const serien = (await tmdb.searchTv(kandidat.text)) ?? [] if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien // Wörtlicher Serientreffer (0,90) for (const serie of serien) { - if (gleichBedeutend(serie.name ?? '', kandidat)) { + if (gleichBedeutend(serie.name ?? '', kandidat.text)) { const details = await tmdb.tvDetails(serie.id) - if (details !== null) { + if (details !== null && !istStub(details)) { const meta = ausDetails('tv', serie.id, details) return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta } } @@ -239,20 +325,24 @@ export async function zuordnen( // Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine // Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht - // (der Evangelion-Fund). + // (der Evangelion-Fund). Stubs zählen dabei nicht mit. if (vollstaendig) { if (filme.length > 0 && filme.length <= WENIGE_TREFFER) { - const details = await tmdb.movieDetails(filme[0].id) - if (details !== null) { - const meta = ausDetails('movie', filme[0].id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } + for (const film of filme) { + const details = await tmdb.movieDetails(film.id) + if (details !== null && !istStub(details)) { + const meta = ausDetails('movie', film.id, details) + return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } + } } } if (serien.length > 0 && serien.length <= WENIGE_TREFFER) { - const details = await tmdb.tvDetails(serien[0].id) - if (details !== null) { - const meta = ausDetails('tv', serien[0].id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } + for (const serie of serien) { + const details = await tmdb.tvDetails(serie.id) + if (details !== null && !istStub(details)) { + const meta = ausDetails('tv', serie.id, details) + return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } + } } } } @@ -262,7 +352,7 @@ export async function zuordnen( // Datensatz wird über TMDb /find (IMDb-ID) nachgeladen. if (omdb.verfuegbar) { for (const kandidat of kandidaten) { - const treffer = await omdb.lookup(kandidat) + const treffer = await omdb.lookup(kandidat.text) if (treffer !== null) { // Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen, // was sie ist. @@ -270,7 +360,7 @@ export async function zuordnen( // Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische // Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream // 5". Ohne echte Wort-Überlappung ist das kein Treffer. - if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue + if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue const meta = ausOmdb(treffer) if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) { const deutsch = await tmdb.findByImdb(meta.id) @@ -288,7 +378,7 @@ export async function zuordnen( } // Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage // ist ein Vorschlag, egal von welchem Dienst er kommt. - const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG + const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta } } } @@ -296,20 +386,26 @@ export async function zuordnen( // 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer // Serien-Disc ist die Filmliste leer, dort zählen nur die Serien. - if (ersteFilme.length > 0) { - const details = await tmdb.movieDetails(ersteFilme[0].id) - if (details !== null) { - const meta = ausDetails('movie', ersteFilme[0].id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } - } + // Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer + // trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt). + let notnagel: Zuordnung | null = null + for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) { + const details = await tmdb.movieDetails(film.id) + if (details === null) continue + const meta = ausDetails('movie', film.id, details) + const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } + if (!istStub(details)) return vorschlag + notnagel ??= vorschlag } - if (ersteSerien.length > 0) { - const details = await tmdb.tvDetails(ersteSerien[0].id) - if (details !== null) { - const meta = ausDetails('tv', ersteSerien[0].id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } - } + for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) { + const details = await tmdb.tvDetails(serie.id) + if (details === null) continue + const meta = ausDetails('tv', serie.id, details) + const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } + if (!istStub(details)) return vorschlag + notnagel ??= vorschlag } + if (notnagel !== null) return notnagel return UNBEKANNT(titel) } @@ -318,3 +414,71 @@ export async function zuordnen( export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean { return zuordnung.confidence >= schwelle } + +/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so + * weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */ +export const LAUFZEIT_PASST = 0.06 +/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */ +export const LAUFZEIT_FALSCH = 0.25 + +/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */ +export interface Messung { + /** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */ + hauptS: number + /** Längen der Folgen-Kandidaten in Sekunden (Serie). */ + folgenS: readonly number[] + /** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */ + struktur: 'film' | 'serie' | 'unklar' +} + +/** + * Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN, + * also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu + * haben ist — die Laufzeit. Aus „wahrscheinlich" wird „bestätigt", oder + * Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers + * (confidence 1) wird nie herabgestuft, nur kommentiert. + */ +export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung { + const hinweise: string[] = [] + let confidence = zuordnung.confidence + const meta = zuordnung.meta + const vomNutzer = zuordnung.confidence >= 1 + const soll = meta.laufzeitMinuten * 60 + + if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) { + const abweichung = Math.abs(messung.hauptS - soll) / soll + const ist = Math.round(messung.hauptS / 60) + if (abweichung <= LAUFZEIT_PASST) { + hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) + if (!vomNutzer) confidence = Math.max(confidence, 0.95) + } else if (abweichung >= LAUFZEIT_FALSCH) { + hinweise.push( + `Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`, + ) + if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) + } else { + hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`) + } + } else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) { + const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length + const abweichung = Math.abs(mittel - soll) / soll + const ist = Math.round(mittel / 60) + if (abweichung <= 0.2) { + hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) + if (!vomNutzer) confidence = Math.max(confidence, 0.8) + } else if (abweichung >= 0.5) { + hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`) + if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) + } + } + + if (messung.struktur === 'serie' && meta.typ === 'movie') { + hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.') + if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) + } else if (messung.struktur === 'film' && meta.typ === 'tv') { + hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.') + if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG) + } + + return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise } +} diff --git a/rippy-windows/src/kern/rip/parser.ts b/rippy-windows/src/kern/rip/parser.ts index 727471c..d672a1a 100644 --- a/rippy-windows/src/kern/rip/parser.ts +++ b/rippy-windows/src/kern/rip/parser.ts @@ -170,11 +170,10 @@ export function parseStreamInfo(ausgabe: string): Map { return ergebnis } -export interface SprachEintrag { - lang: string - sprache: string - spuren: number -} +// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt +// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/. +import type { SprachEintrag } from '../../gemeinsam/nachrichten' +export type { SprachEintrag } /** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt, * Reihenfolge stabil (wie auf der Disc). */ @@ -200,6 +199,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map return { audio: sammeln('audio'), untertitel: sammeln('untertitel') } } +/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe + * Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams + * (Titel unbekannt) leer, nicht erfunden. */ +export function sprachenFuerTitel(streams: TitelStreams | undefined): { + audio: SprachEintrag[] + untertitel: SprachEintrag[] +} { + if (streams === undefined) return { audio: [], untertitel: [] } + return sprachenZusammenfassen(new Map([[0, streams]])) +} + // ── Titelwahl-Heuristiken ─────────────────────────────────────────────── /** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus * TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das diff --git a/rippy-windows/src/kern/rip/titelwahl.ts b/rippy-windows/src/kern/rip/titelwahl.ts index 06fde7d..e142735 100644 --- a/rippy-windows/src/kern/rip/titelwahl.ts +++ b/rippy-windows/src/kern/rip/titelwahl.ts @@ -62,6 +62,24 @@ export interface DiscHinweis { folgen: number } +/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: „1 langer + + * viele kurze = Film; 6–13 gleich lange = Serienstaffel"). Bewusst + * ZURÜCKHALTEND — 'unklar' ist die häufigste und ehrlichste Antwort: + * * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min + * * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt + * Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine + * Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */ +export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' { + const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS) + if (lang.length === 0) return 'unklar' + const laengster = lang[0].dauerS + const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH) + if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie' + const zweiter = lang[1]?.dauerS ?? 0 + if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film' + return 'unklar' +} + function dauerText(s: number): string { const m = Math.round(s / 60) return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min` diff --git a/rippy-windows/src/kern/speicher/db.ts b/rippy-windows/src/kern/speicher/db.ts index 91edbad..d1cd6f6 100644 --- a/rippy-windows/src/kern/speicher/db.ts +++ b/rippy-windows/src/kern/speicher/db.ts @@ -1,18 +1,53 @@ // Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für // Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch, // dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der -// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus -// einer Konfigurationsdatei, die niemand schrieb. +// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las +// aus einer Konfigurationsdatei, die niemand schrieb. // // node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im // utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) — // keine native Kompilierung, kein node-gyp in der Bau-Kette. +// +// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach +// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und +// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen), +// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch +// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz. import { DatabaseSync } from 'node:sqlite' import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' -import type { BibliothekEintrag } from '../../gemeinsam/nachrichten' +import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten' -export const SCHEMA_VERSION = 3 +export const SCHEMA_VERSION = 4 + +/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */ +export interface Vorgang { + /** Der Vorgangsname — zugleich der Name des Roh-Ordners. */ + id: string + laufwerk: string + typ: string + titel: string + jahr: number | null + phase: VorgangPhase + begonnen: string + beendet: string | null + rohOrdner: string + rohBytes: number + rohGeloeschtAm: string | null + zielOrdner: string + fertigBytes: number + /** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */ + komprimiert: boolean + preset: string + fehler: string + /** Der Kompressions-Auftrag als JSON — Grundlage für „ab Kompression + * wiederholen" (kern/ablauf/auftrag.ts). */ + auftrag: string + lesefehler: boolean + /** Die fertigen Dateien als JSON-Liste — das Aufräumen prüft, dass sie + * wirklich da sind, BEVOR es den Roh-Ordner löscht. */ + fertigeDateien: string +} export class Datenbank { readonly pfad: string @@ -48,6 +83,27 @@ export class Datenbank { datum TEXT NOT NULL, dauer_s INTEGER NOT NULL, poster_pfad TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS vorgaenge ( + id TEXT PRIMARY KEY, + laufwerk TEXT NOT NULL, + typ TEXT NOT NULL, + titel TEXT NOT NULL, + jahr INTEGER, + phase TEXT NOT NULL, + begonnen TEXT NOT NULL, + beendet TEXT, + roh_ordner TEXT NOT NULL, + roh_bytes INTEGER NOT NULL DEFAULT 0, + roh_geloescht_am TEXT, + ziel_ordner TEXT NOT NULL DEFAULT '', + fertig_bytes INTEGER NOT NULL DEFAULT 0, + komprimiert INTEGER NOT NULL DEFAULT 0, + preset TEXT NOT NULL DEFAULT '', + fehler TEXT NOT NULL DEFAULT '', + auftrag TEXT NOT NULL DEFAULT '{}', + lesefehler INTEGER NOT NULL DEFAULT 0, + fertige_dateien TEXT NOT NULL DEFAULT '[]' );`, ) // Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne @@ -129,7 +185,91 @@ export class Datenbank { return zeile === undefined ? null : zeile.datum } + // ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ────────── + vorgangAnlegen(v: Vorgang): void { + this.db + .prepare( + `INSERT OR REPLACE INTO vorgaenge + (id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes, + roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag, + lesefehler, fertige_dateien) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + v.id, + v.laufwerk, + v.typ, + v.titel, + v.jahr, + v.phase, + v.begonnen, + v.beendet, + v.rohOrdner, + v.rohBytes, + v.rohGeloeschtAm, + v.zielOrdner, + v.fertigBytes, + v.komprimiert ? 1 : 0, + v.preset, + v.fehler, + v.auftrag, + v.lesefehler ? 1 : 0, + v.fertigeDateien, + ) + } + + /** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */ + vorgangAktualisieren(id: string, teil: Partial>): void { + const alt = this.vorgangLesen(id) + if (alt === null) return + this.vorgangAnlegen({ ...alt, ...teil, id }) + } + + vorgangLesen(id: string): Vorgang | null { + const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as + | Record + | undefined + return zeile === undefined ? null : vorgangAusZeile(zeile) + } + + /** Alle Vorgänge, neueste zuerst. */ + vorgangListe(): Vorgang[] { + const zeilen = this.db + .prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`) + .all() as Array> + return zeilen.map(vorgangAusZeile) + } + + vorgangLoeschen(id: string): void { + this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id) + } + schliessen(): void { this.db.close() } } + +function vorgangAusZeile(z: Record): Vorgang { + return { + id: String(z.id), + laufwerk: String(z.laufwerk), + typ: String(z.typ), + titel: String(z.titel), + jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr), + phase: String(z.phase) as VorgangPhase, + begonnen: String(z.begonnen), + beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet), + rohOrdner: String(z.roh_ordner), + rohBytes: Number(z.roh_bytes ?? 0), + rohGeloeschtAm: + z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am), + zielOrdner: String(z.ziel_ordner ?? ''), + fertigBytes: Number(z.fertig_bytes ?? 0), + komprimiert: Number(z.komprimiert ?? 0) === 1, + preset: String(z.preset ?? ''), + fehler: String(z.fehler ?? ''), + auftrag: String(z.auftrag ?? '{}'), + lesefehler: Number(z.lesefehler ?? 0) === 1, + fertigeDateien: String(z.fertige_dateien ?? '[]'), + } +} diff --git a/rippy-windows/src/kern/werkzeuge/spurauswahl.ts b/rippy-windows/src/kern/werkzeuge/spurauswahl.ts new file mode 100644 index 0000000..edd0932 --- /dev/null +++ b/rippy-windows/src/kern/werkzeuge/spurauswahl.ts @@ -0,0 +1,78 @@ +// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der +// Roh-Datei landen, die der Auswahl-Dialog anbietet. +// +// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in +// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku +// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D) +// nennt als Regel des Standard-Profils +// +// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang +// +// mit: favlang = „matches favorite languages, always matches if no +// favorite language is set" · havemulti = „track is mono/stereo and there +// is a multi-channel track in same language" · havecore = „this track is +// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht. +// +// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles +// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs +// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton +// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei +// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig +// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine +// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts). +// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest +// dort, die settings.conf ist der Linux-Weg). +import { spawnSync } from 'node:child_process' + +export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV' +export const REGEL_NAME = 'app_DefaultSelectionString' +/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */ +export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all' + +export interface SpurauswahlStand { + /** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) | + * 'rippy' (Rippys Regel steht schon) | 'fehler' */ + stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler' + regel: string + text: string +} + +function reg(args: string[]): { ok: boolean; ausgabe: string } { + const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' }) + return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') } +} + +/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar): + * „ app_DefaultSelectionString REG_SZ -sel:all,…" */ +export function regelAusQuery(ausgabe: string): string | null { + const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe) + return treffer === null ? null : treffer[1].trim() +} + +/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */ +export function spurauswahlSicherstellen(): SpurauswahlStand { + if (process.platform !== 'win32') { + return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' } + } + const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME]) + const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null + if (bisher !== null && bisher.length > 0) { + if (bisher === RIPPY_REGEL) { + return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' } + } + return { + stand: 'eigen', + regel: bisher, + text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`, + } + } + const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f']) + if (!gesetzt.ok) { + return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` } + } + return { + stand: 'gesetzt', + regel: RIPPY_REGEL, + text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).', + } +} diff --git a/rippy-windows/test/ablegen.test.ts b/rippy-windows/test/ablegen.test.ts new file mode 100644 index 0000000..890d131 --- /dev/null +++ b/rippy-windows/test/ablegen.test.ts @@ -0,0 +1,301 @@ +// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit +// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die +// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn +// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler +// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je +// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei. +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag' +import type { Zuordnung } from '../src/kern/metadaten/zuordnung' +import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung' + +const hb = vi.hoisted(() => ({ + aufrufe: [] as Array>, + ergebnis: 'success' as 'success' | 'error' | 'cancelled', +})) + +vi.mock('../src/kern/komprimieren/handbrake', async () => { + const fs = await import('node:fs') + return { + komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => { + hb.aufrufe.push({ ...o }) + o.aufFortschritt?.(50) + if (hb.ergebnis === 'cancelled') return { status: 'cancelled' } + if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' } + fs.writeFileSync(o.ausgabe, 'encoded') + return { status: 'success', ausgabe: o.ausgabe } + }), + } +}) + +import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen' + +let wegwerf: string[] = [] +afterEach(() => { + hb.aufrufe = [] + hb.ergebnis = 'success' + for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true }) +}) + +function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung { + return { + titel, + jahr, + confidence: 0.95, + meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 }, + } +} + +function aufbau(dateinamen: string[], rollen: Array = []): { + ablage: string + roh: string + dateien: RohDatei[] +} { + const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-')) + wegwerf.push(ablage) + const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00') + mkdirSync(roh, { recursive: true }) + const dateien = dateinamen.map((name, i) => { + const pfad = join(roh, name) + writeFileSync(pfad, 'roh'.repeat(10)) + return { + pfad, + titelNr: i, + rolle: rollen[i] ?? 'hauptfilm', + dauerS: 3167, + groesseBytes: 30, + audioSprachen: [], + untertitelSprachen: [], + } + }) + return { ablage, roh, dateien } +} + +function auftrag(teil: Partial & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag { + return { + vorgangId: 'bluray-2026-08-30T12-00-00', + laufwerk: 'G', + discTyp: 'bluray', + zuordnung: null, + staffel: null, + ersteFolge: null, + discNr: null, + fingerprint: 'LABEL|33', + lesefehler: false, + hinweise: [], + begonnen: '2026-08-30T12:00:00Z', + preset: '', + zielOrdner: '', + ...teil, + } +} + +function umgebung(ablage: string, überschreiben: Partial = {}): AblageUmgebung { + return { + handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', + ablage: () => ablage, + einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }), + jetzt: () => new Date('2026-08-30T13:00:00Z'), + spurenScannen: async () => null, + ...überschreiben, + } +} + +const KEIN_ABBRUCH = new AbortController().signal + +describe('komprimierenUndAblegen', () => { + it('komprimiert in Filme/ mit NFO und meldet die Phasen', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const bibliothek: string[] = [] + const phasen: string[] = [] + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }), + umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }), + (s) => phasen.push(s.phase), + KEIN_ABBRUCH, + ) + expect(ergebnis.status).toBe('fertig') + expect(ergebnis.komprimiert).toBe(true) + expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)')) + expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) + expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'title_t00.mkv')]) + expect(phasen).toContain('komprimiert') + expect(phasen.at(-1)).toBe('legt-ab') + expect(bibliothek).toEqual(['Akira']) + expect(ergebnis.text).toContain('komprimiert nach') + }) + + it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }), + umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.status).toBe('fertig') + expect(ergebnis.komprimiert).toBe(false) + expect(ergebnis.text).toContain('ohne Kompression') + expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true) + expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) + expect(readdirSync(roh)).toEqual([]) + expect(hb.aufrufe).toHaveLength(0) + }) + + it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien }), + umgebung(ablage, { handbrake: () => '' }), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.komprimiert).toBe(false) + expect(readdirSync(roh)).toEqual([]) + expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00')) + }) + + it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => { + hb.ergebnis = 'error' + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) + expect(ergebnis.status).toBe('fehler') + expect(ergebnis.text).toContain('bleibt stehen') + expect(ergebnis.text).toContain(roh) + expect(existsSync(dateien[0].pfad)).toBe(true) + }) + + it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => { + hb.ergebnis = 'cancelled' + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) + expect(ergebnis.status).toBe('abgebrochen') + expect(existsSync(dateien[0].pfad)).toBe(true) + }) + + it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv']) + dateien[0].audioSprachen = ['jpn'] + dateien[0].untertitelSprachen = ['eng', 'deu'] + const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) + expect(ergebnis.status).toBe('fertig') + expect(hb.aufrufe).toHaveLength(2) + // Datei 0: eigene Wahl — deu nach vorn, Vorwahl an. + expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn']) + expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng']) + expect(hb.aufrufe[0].untertitelStandard).toBe(true) + // Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl. + expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng']) + expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu']) + expect(hb.aufrufe[1].untertitelStandard).toBe(false) + }) + + it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + dateien[0].audioSprachen = ['jpn'] + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien }), + umgebung(ablage, { + spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }), + }), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei') + }) + + it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) + dateien[1].dauerS = 3249 + const ergebnis = await komprimierenUndAblegen( + auftrag({ + rohOrdner: roh, + dateien, + zuordnung: zuordnung('tv', 'Spartacus', 2010), + staffel: 1, + ersteFolge: 3, + }), + umgebung(ablage, { + // Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber + // die Hand des Nutzers gilt. + episodenLaufzeiten: async () => [ + [3, 3200], + [4, 2000], + ], + }), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.status).toBe('fertig') + expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01')) + expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ + 'Spartacus S01E03.mkv', + 'Spartacus S01E04.mkv', + ]) + expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv']) + expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min') + expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true) + }) + + it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) + dateien[0].dauerS = 3167 + dateien[1].dauerS = 3249 + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }), + umgebung(ablage, { + episodenLaufzeiten: async () => [ + [1, 3180], + [2, 3240], + ], + }), + () => {}, + KEIN_ABBRUCH, + ) + expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ + 'Spartacus S02E01.mkv', + 'Spartacus S02E02.mkv', + ]) + }) + + it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra']) + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }), + umgebung(ablage), + () => {}, + KEIN_ABBRUCH, + ) + expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true) + expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true) + }) + + it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) + const ziel = join(ablage, 'Filme', 'Akira (1988)') + mkdirSync(ziel, { recursive: true }) + const ergebnis = await komprimierenUndAblegen( + auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }), + umgebung(ablage), + () => {}, + KEIN_ABBRUCH, + ) + expect(ergebnis.zielOrdner).toBe(ziel) + expect(ergebnis.preset).toBe('Fast 1080p30') + expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)']) + }) + + it('der Fortschritt ist über beide Dateien gewichtet', async () => { + const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv']) + dateien[0].groesseBytes = 3 + dateien[1].groesseBytes = 1 + const prozente: number[] = [] + await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH) + // 50 % der ersten (3/4) = 37, 50 % der zweiten = 87. + expect(prozente).toContain(37) + expect(prozente).toContain(87) + }) +}) diff --git a/rippy-windows/test/auftrag.test.ts b/rippy-windows/test/auftrag.test.ts new file mode 100644 index 0000000..4992edc --- /dev/null +++ b/rippy-windows/test/auftrag.test.ts @@ -0,0 +1,118 @@ +// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur: +// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON. +import { describe, expect, it } from 'vitest' +import { + auftragAusJson, + auftragZuJson, + folgenNummern, + folgenPlausibel, + gewichteterFortschritt, + sprachwahl, + type KompressionsAuftrag, +} from '../src/kern/ablauf/auftrag' + +describe('sprachwahl — die Regel des Commanders', () => { + const global = { audio: ['deu', 'eng'], untertitel: ['deu'] } + + it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => { + expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({ + audio: ['deu', 'eng'], + untertitel: ['deu'], + untertitelStandard: false, + }) + }) + + it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => { + expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({ + audio: ['jpn'], + untertitel: ['deu', 'eng'], + untertitelStandard: true, + }) + }) + + it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => { + expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false) + }) + + it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => { + expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({ + audio: ['jpn'], + untertitel: ['eng'], + untertitelStandard: false, + }) + }) + + it('ohne globale Wunschsprache gibt es keine Vorwahl', () => { + expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false) + }) +}) + +describe('folgenNummern und folgenPlausibel', () => { + it('fortlaufend ab der ersten Folge', () => { + expect(folgenNummern(2, 3)).toEqual([3, 4]) + expect(folgenNummern(0, 3)).toEqual([]) + expect(folgenNummern(2, 0)).toEqual([1, 2]) + }) + + it('warnt nur bei deutlicher Abweichung von TMDb', () => { + const laufzeiten: Array<[number, number]> = [ + [3, 3200], + [4, 2000], + ] + const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten) + expect(w).toHaveLength(1) + expect(w[0]).toContain('Folge 4') + expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([]) + }) +}) + +describe('gewichteterFortschritt — nie zurück auf null', () => { + it('gewichtet nach Größe', () => { + expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37) + expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75) + expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87) + expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100) + }) + it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => { + expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75) + expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50) + expect(gewichteterFortschritt([], 0, 50)).toBe(-1) + }) +}) + +describe('Auftrag als JSON — und wieder zurück', () => { + const a: KompressionsAuftrag = { + vorgangId: 'bluray-2026-08-30T12-00-00', + laufwerk: 'G', + discTyp: 'bluray', + rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00', + dateien: [ + { pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, + ], + zuordnung: null, + staffel: 1, + ersteFolge: 3, + discNr: 2, + fingerprint: 'SPARTACUS_GOTA_D2|33759100928', + lesefehler: false, + hinweise: ['Übersprungen: Titel 5.'], + begonnen: '2026-08-30T12:00:00.000Z', + preset: '', + zielOrdner: '', + } + it('kommt unverändert zurück', () => { + expect(auftragAusJson(auftragZuJson(a))).toEqual(a) + }) + it('Unsinn ist null, kein Absturz', () => { + expect(auftragAusJson('{')).toBeNull() + expect(auftragAusJson('{}')).toBeNull() + expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull() + }) + it('fehlende Felder bekommen Vorgaben', () => { + const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')! + expect(b.dateien[0].rolle).toBe('hauptfilm') + expect(b.dateien[0].audioSprachen).toEqual([]) + expect(b.staffel).toBeNull() + expect(b.preset).toBe('') + }) +}) diff --git a/rippy-windows/test/db.test.ts b/rippy-windows/test/db.test.ts index f97e0e2..2d17bec 100644 --- a/rippy-windows/test/db.test.ts +++ b/rippy-windows/test/db.test.ts @@ -71,3 +71,59 @@ describe('Datenbank', () => { expect(() => new Datenbank(kaputt)).toThrow() }) }) + +// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ────────── +describe('Datenbank — Vorgänge', () => { + const vorgang = { + id: 'bluray-2026-09-01T20-00-00', + laufwerk: 'G', + typ: 'bluray', + titel: 'Spartacus', + jahr: 2010, + phase: 'rippt' as const, + begonnen: '2026-09-01T20:00:00.000Z', + beendet: null, + rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00', + rohBytes: 0, + rohGeloeschtAm: null, + zielOrdner: '', + fertigBytes: 0, + komprimiert: false, + preset: '', + fehler: '', + auftrag: '{}', + lesefehler: false, + fertigeDateien: '[]', + } + + it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => { + const db = new Datenbank(':memory:') + aufraeumen.push(() => db.schliessen()) + db.vorgangAnlegen(vorgang) + expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang) + db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' }) + const neu = db.vorgangLesen(vorgang.id)! + expect(neu.phase).toBe('fertig') + expect(neu.komprimiert).toBe(true) + expect(neu.fertigBytes).toBe(4800) + expect(neu.titel).toBe('Spartacus') + expect(neu.rohGeloeschtAm).toBeNull() + db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' }) + expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id]) + db.vorgangLoeschen(vorgang.id) + expect(db.vorgangLesen(vorgang.id)).toBeNull() + expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined() + }) + + it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => { + const pfad = frischerPfad() + const alt = new Datenbank(pfad) + alt.einstellungSchreiben('ablage', 'E:\Rippy') + alt.schliessen() + const neu = new Datenbank(pfad) + aufraeumen.push(() => neu.schliessen()) + expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy') + expect(neu.vorgangListe()).toEqual([]) + expect(neu.schemaVersion()).toBe(SCHEMA_VERSION) + }) +}) diff --git a/rippy-windows/test/ereignisse.test.ts b/rippy-windows/test/ereignisse.test.ts new file mode 100644 index 0000000..0334efe --- /dev/null +++ b/rippy-windows/test/ereignisse.test.ts @@ -0,0 +1,70 @@ +// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) — +// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7 +// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler, +// der USB-Aussetzer). +import { describe, expect, it } from 'vitest' +import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse' + +/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */ +const XML = + "702135751SystemTobisNicerPC\\Device\\CdRom00300" + + "7System\\Device\\CdRom0" + + "11System\\Device\\CdRom0" + +describe('ereignisseAusXml', () => { + it('liest Provider, ID, Zeit und Gerät je Ereignis', () => { + const e = ereignisseAusXml(XML) + expect(e).toHaveLength(3) + expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' }) + expect(e[2].id).toBe(11) + }) + it('leere Ausgabe = keine Ereignisse', () => { + expect(ereignisseAusXml('')).toEqual([]) + expect(ereignisseAusXml('\r\n')).toEqual([]) + }) +}) + +describe('beurteilen — Disc oder Hardware?', () => { + const alle = ereignisseAusXml(XML) + + it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => { + const b = beurteilen(alle, 24) + expect(b.urteil).toBe('hardware') + expect(b.controllerfehler).toBe(1) + expect(b.fehlerhafteBloecke).toBe(2) + expect(b.text).toContain('Mainboard') + expect(b.letzte).toHaveLength(3) + expect(b.letzte[0]).toContain('fehlerhafter Block') + }) + + it('nur fehlerhafte Blöcke: die Disc', () => { + const b = beurteilen(alle.filter((e) => e.id === 7), 24) + expect(b.urteil).toBe('disc') + expect(b.text).toContain('DISC') + }) + + it('nichts: ruhig', () => { + const b = beurteilen([], 24) + expect(b.urteil).toBe('ruhig') + expect(b.text).toContain('nichts') + }) + + it('seit einem Zeitpunkt: nur die jüngeren zählen', () => { + const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z')) + expect(b.controllerfehler).toBe(0) + expect(b.urteil).toBe('disc') + }) +}) + +describe('die wevtutil-Argumente (Regel D: so gemessen)', () => { + it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => { + expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]") + const args = buildWevtutilArgs(24, 50) + expect(args[0]).toBe('qe') + expect(args[1]).toBe('System') + expect(args[2]).toBe(`/q:${xpathFuer(24)}`) + expect(args).toContain('/f:xml') + expect(args).toContain('/c:50') + expect(args).toContain('/rd:true') + }) +}) diff --git a/rippy-windows/test/handbrake.test.ts b/rippy-windows/test/handbrake.test.ts index 2654abd..24d925b 100644 --- a/rippy-windows/test/handbrake.test.ts +++ b/rippy-windows/test/handbrake.test.ts @@ -182,3 +182,22 @@ describe('presets', () => { expect(sprachliste(null)).toEqual([]) }) }) + +describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11 gemessen)', () => { + it('mit Sprachliste und untertitelStandard kommt --subtitle-default=1 dazu', () => { + const args = buildHandbrakeArgs({ + eingabe: 'a.mkv', + ausgabe: 'b.mkv', + preset: 'X', + audioSprachen: ['jpn'], + untertitelSprachen: ['deu', 'eng'], + untertitelStandard: true, + }) + expect(args).toContain('--subtitle-default=1') + expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu,eng') + }) + it('ohne Sprachliste KEINE Vorwahl — die Reihenfolge wäre nicht bestimmt', () => { + const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X', untertitelStandard: true }) + expect(args).not.toContain('--subtitle-default=1') + }) +}) diff --git a/rippy-windows/test/metadaten-v2.test.ts b/rippy-windows/test/metadaten-v2.test.ts new file mode 100644 index 0000000..98d5083 --- /dev/null +++ b/rippy-windows/test/metadaten-v2.test.ts @@ -0,0 +1,289 @@ +// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten +// (01.09.2026, TMDb mit dem hinterlegten v4-Token): +// movie/149 (Akira): tagline, runtime 124, vote_average 7.94, +// images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE +// mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV). +// tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'. +// Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop +// null, 1 Folge, Popularität 1. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale' +import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb' +import { + LEERER_TREFFER, + VORSCHLAG, + ausDetails, + istStub, + kandidatenAus, + nachpruefen, + zuordnen, + type Zuordnung, +} from '../src/kern/metadaten/zuordnung' +import { strukturHinweis } from '../src/kern/rip/titelwahl' + +let wegwerf: string[] = [] +afterEach(() => { + for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true }) +}) + +/** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */ +const AKIRA: FilmDetails = { + id: 149, + title: 'Akira', + release_date: '1988-06-10', + overview: 'Tokio 1988. In einem Geheimlabor der Armee …', + poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg', + backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg', + runtime: 124, + genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }], + tagline: 'Neo-Tokyo ist am EXPLODIEREN', + vote_average: 7.94, + vote_count: 4944, + images: { + logos: [ + { iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 }, + { iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 }, + { iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 }, + { iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 }, + ], + }, + credits: { + cast: [ + { name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 }, + { name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 }, + { name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 }, + ], + }, + release_dates: { + results: [ + { iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] }, + { + iso_3166_1: 'DE', + release_dates: [ + { certification: '16', type: 5 }, + { certification: '16', type: 3 }, + { certification: '', type: 6 }, + ], + }, + ], + }, +} + +const SPARTACUS_TV: FilmDetails = { + id: 46296, + name: 'Spartacus', + first_air_date: '2010-01-22', + overview: 'Der Thraker Spartacus …', + backdrop_path: '/x.jpg', + vote_average: 8, + vote_count: 1000, + episode_run_time: [53], + number_of_episodes: 39, + content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] }, +} + +/** Einer der drei gemessenen Stubs. */ +const STUB: FilmDetails = { + id: 331850, + name: 'Spartacus: Gods of the Arena', + first_air_date: '', + overview: '', + backdrop_path: null, + vote_average: 8, + vote_count: 0, + number_of_episodes: 1, + episode_run_time: [], +} + +describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => { + it('logoAus: deutsch vor englisch, darin das bestbewertete', () => { + expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png') + expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png') + expect(logoAus([])).toBe('') + expect(logoAus(undefined)).toBe('') + }) + + it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => { + expect(fskAus(AKIRA, 'movie')).toBe('16') + expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18') + expect(fskAus({ id: 1 }, 'movie')).toBe('') + expect(fskAus(STUB, 'tv')).toBe('') + }) + + it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => { + const b = besetzungAus(AKIRA.credits?.cast) + expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama']) + expect(b[0].rolle).toBe('Shotaro Kaneda (voice)') + expect(b[2].bildPfad).toBe('') + expect(besetzungAus(undefined)).toEqual([]) + }) + + it('ausDetails trägt alles in den Treffer — Film und Serie', () => { + const film = ausDetails('movie', 149, AKIRA) + expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN') + expect(film.bewertung).toBe(7.9) + expect(film.fsk).toBe('16') + expect(film.logoPfad).toContain('.png') + expect(film.besetzung).toHaveLength(3) + expect(film.laufzeitMinuten).toBe(124) + expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action']) + const serie = ausDetails('tv', 46296, SPARTACUS_TV) + expect(serie.laufzeitMinuten).toBe(53) + expect(serie.folgenGesamt).toBe(39) + expect(serie.fsk).toBe('18') + }) + + it('istStub erkennt die leeren Einträge — und nichts anderes', () => { + expect(istStub(STUB)).toBe(true) + expect(istStub(AKIRA)).toBe(false) + expect(istStub(SPARTACUS_TV)).toBe(false) + // Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub. + expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false) + }) +}) + +describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => { + const tmdb = { + verfuegbar: true, + searchMovie: async () => [], + searchTv: async (t: string) => + t === 'Spartacus: Gods of the Arena' + ? [ + { id: 331850, name: 'Spartacus: Gods of the Arena' }, + { id: 331915, name: 'Spartacus Gods of the Arena' }, + ] + : t === 'Spartacus' + ? [{ id: 46296, name: 'Spartacus' }] + : [], + movieDetails: async () => null, + tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }), + findByImdb: async () => null, + } + const keinOmdb = { verfuegbar: false, lookup: async () => null } + + it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => { + const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true }) + // Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt). + expect(z.titel).toBe('Spartacus') + expect(z.confidence).toBe(VORSCHLAG) + expect(z.meta.laufzeitMinuten).toBe(53) + }) + + it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => { + const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) } + const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true }) + expect(z.confidence).toBe(VORSCHLAG) + expect(z.titel).toBe('Spartacus: Gods of the Arena') + }) +}) + +describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => { + it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => { + const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2']) + expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2') + expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2') + expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena') + expect(k[1].massstab).toBe('Spartacus: Götter der Arena') + expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length) + }) + it('ohne Alternativen wie bisher', () => { + expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira']) + }) +}) + +describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => { + const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) } + + it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => { + const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' }) + expect(z.confidence).toBe(0.95) + expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt') + }) + + it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => { + const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } } + const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' }) + expect(z.confidence).toBe(VORSCHLAG) + expect(z.hinweise?.[0]).toContain('passt NICHT') + }) + + it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => { + const eigene = { ...akira, confidence: 1 } + const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' }) + expect(z.confidence).toBe(1) + expect(z.hinweise).toHaveLength(1) + }) + + it('Serie: Folgenlänge gegen episode_run_time', () => { + const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) } + const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' }) + expect(z.confidence).toBe(0.8) + expect(z.hinweise?.[0]).toContain('Folgenlänge passt') + }) + + it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => { + const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' }) + expect(z.confidence).toBe(VORSCHLAG) + expect(z.hinweise?.[0]).toContain('Serienstaffel') + }) + + it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => { + const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } } + const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' }) + expect(z.confidence).toBe(0.8) + expect(z.hinweise).toEqual([]) + }) +}) + +describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => { + const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 }) + it('ein langer + viele kurze = Film', () => { + expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film') + }) + it('sechs gleich lange = Serie', () => { + expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie') + }) + it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => { + expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar') + }) + it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => { + expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar') + }) + it('nichts Langes: unklar', () => { + expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar') + expect(strukturHinweis([])).toBe('unklar') + }) +}) + +describe('bdmt_deu als zweite Titel-Variante', () => { + it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => { + const z = bdmtZerlegen('TitelEP 1') + expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] }) + }) + + it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => { + const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-')) + wegwerf.push(wurzel) + const meta = join(wurzel, 'BDMV', 'META', 'DL') + mkdirSync(meta, { recursive: true }) + writeFileSync(join(meta, 'bdmt_deu.xml'), 'Spartacus: Götter der Arena') + writeFileSync(join(meta, 'bdmt_eng.xml'), 'Spartacus: Gods of the ArenaEP 1EP 2') + writeFileSync(join(meta, 'bdmt_fra.xml'), 'Spartacus: Gods of the Arena') + const inhalt = inhaltAusBdmt(wurzel) + expect(inhalt.titel).toBe('Spartacus: Gods of the Arena') + expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2']) + expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena']) + }) + + it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => { + const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-')) + wegwerf.push(wurzel) + const meta = join(wurzel, 'BDMV', 'META', 'DL') + mkdirSync(meta, { recursive: true }) + writeFileSync(join(meta, 'bdmt_deu.xml'), 'Nur Deutsch') + expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] }) + }) +}) diff --git a/rippy-windows/test/metadaten.test.ts b/rippy-windows/test/metadaten.test.ts index 456f88d..ff00a3e 100644 --- a/rippy-windows/test/metadaten.test.ts +++ b/rippy-windows/test/metadaten.test.ts @@ -109,7 +109,9 @@ function fakeTmdb(filme: Record, serien: Record { for (const liste of Object.values(serien)) { const treffer = liste.find((f) => f.id === id) - if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' } + // Eine ECHTE Serie hat eine Beschreibung — ohne sie wäre es ein + // leerer Eintrag (istStub, 5.4.0), und der zählt nicht als Treffer. + if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10', overview: 'Ü', ...treffer.details } } return null }, @@ -292,7 +294,7 @@ describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => { it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => { const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-')) wegwerf.push(wurzel) - expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] }) + expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [], alternativen: [] }) }) }) diff --git a/rippy-windows/test/nachrichten.test.ts b/rippy-windows/test/nachrichten.test.ts index 1ee6c6f..2f8895c 100644 --- a/rippy-windows/test/nachrichten.test.ts +++ b/rippy-windows/test/nachrichten.test.ts @@ -71,3 +71,47 @@ describe('istHauptNachricht', () => { expect(istHauptNachricht(undefined)).toBe(false) }) }) + +// ── 5.4.0: Auftrag mit Sprachen und Optionen, neue Nachrichten ────────── +describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)', () => { + it('nimmt Sprachlisten und Staffel/Folge an', () => { + expect( + istFensterNachricht({ + art: 'rip-start', + id: 'G', + auftrag: [{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }], + optionen: { staffel: 1, ersteFolge: 3 }, + }), + ).toBe(true) + expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: null, ersteFolge: null } })).toBe(true) + }) + it('lehnt kaputte Sprachlisten, Rollen und Optionen ab', () => { + expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'folge', dauerS: 1, audioSprachen: 'jpn' }] })).toBe(false) + expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'egal', dauerS: 1 }] })).toBe(false) + expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: 'eins' } })).toBe(false) + }) + it('kennt die neuen Nachrichten', () => { + expect(istFensterNachricht({ art: 'vorgaenge-laden' })).toBe(true) + expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: 'x' })).toBe(true) + expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: '' })).toBe(false) + expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 'Fast' })).toBe(true) + expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 5 })).toBe(false) + expect(istFensterNachricht({ art: 'kompression-abbrechen', vorgangId: 'x' })).toBe(true) + expect(istFensterNachricht({ art: 'roh-aufraeumen' })).toBe(true) + expect(istFensterNachricht({ art: 'fremden-ordner-loeschen', ordner: 'E:\Rippy\roh\alt' })).toBe(true) + expect(istFensterNachricht({ art: 'laufwerk-pruefen', id: 'G' })).toBe(true) + }) +}) + +describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => { + it('vorgaenge braucht Liste, Belegung und Warteschlange', () => { + expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true) + expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false) + }) + it('kompression-ergebnis und laufwerk-gesundheit', () => { + expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', titel: 'T', ok: true, text: 'fertig' })).toBe(true) + expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', ok: true })).toBe(false) + expect(istKernNachricht({ art: 'laufwerk-gesundheit', id: 'G', bericht: { urteil: 'disc', text: 't', fehlerhafteBloecke: 1, controllerfehler: 0, sonstige: 0, letzte: [], stunden: 24 } })).toBe(true) + expect(istKernNachricht({ art: 'kern-hinweis', text: 'Roh-Ordner gelöscht' })).toBe(true) + }) +}) diff --git a/rippy-windows/test/pipeline.test.ts b/rippy-windows/test/pipeline.test.ts index 344497b..7dc71f0 100644 --- a/rippy-windows/test/pipeline.test.ts +++ b/rippy-windows/test/pipeline.test.ts @@ -1,170 +1,261 @@ -// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten -// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake -// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt -// wo er liegt; Statusmeldungen tragen die Phase. -import { mkdtempSync, rmSync } from 'node:fs' +// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt +// danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk +// ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft +// test/ablegen.test.ts. +// +// makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner — +// nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler +// wirklich verschwinden. +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RipStatus } from '../src/gemeinsam/nachrichten' +import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten' +import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag' -const ripErgebnis = vi.hoisted(() => ({ - wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown, -})) -const encodeErgebnis = vi.hoisted(() => ({ - wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown, -})) +interface Schritt { + status: 'success' | 'error' | 'cancelled' + /** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */ + datei?: string + bytes?: number + lesefehler?: boolean + fehler?: string + /** Fortschritts-Ticks, die der Lauf meldet. */ + ticks?: number[] +} -vi.mock('../src/kern/rip/makemkv', () => ({ - rippen: vi.fn(async () => ripErgebnis.wert), -})) -vi.mock('../src/kern/komprimieren/handbrake', () => ({ - komprimieren: vi.fn(async () => encodeErgebnis.wert), -})) +const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] })) -import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline' +vi.mock('../src/kern/rip/makemkv', async () => { + const fs = await import('node:fs') + const pfad = await import('node:path') + return { + rippen: vi.fn( + async (o: { + zielOrdner: string + aufFortschritt?: (p: number) => void + }) => { + const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' } + for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p) + if (schritt.datei !== undefined) { + fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10)) + } + if (schritt.status === 'cancelled') return { status: 'cancelled' } + if (schritt.status === 'error') { + return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false } + } + const dateien = fs + .readdirSync(o.zielOrdner) + .filter((n: string) => n.endsWith('.mkv')) + .sort() + .map((n: string) => pfad.join(o.zielOrdner, n)) + return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false } + }, + ), + } +}) + +import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline' let wegwerf: string[] = [] afterEach(() => { + plan.schritte = [] for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true }) }) +const GERAET = { + id: 'G', + name: 'Laufwerk G:', + typ: 'bluray' as const, + pfad: '\\\\.\\G:', + status: 'ready' as const, + modell: '', + seriennummer: '', + groesseBytes: 33e9, + grund: '', +} + function umgebung(überschreiben: Partial = {}): { pipeline: Pipeline meldungen: RipStatus[] + ablage: string + eingereiht: KompressionsAuftrag[] + endet: Array<{ id: string; phase: string; text: string }> + ausgeworfen: string[] } { const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-')) wegwerf.push(ablage) const meldungen: RipStatus[] = [] + const eingereiht: KompressionsAuftrag[] = [] + const endet: Array<{ id: string; phase: string; text: string }> = [] + const ausgeworfen: string[] = [] const pipeline = new Pipeline({ makemkvcon: () => 'C:\\mk\\makemkvcon64.exe', handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', flac: () => '', - geraet: () => ({ - id: 'G', - name: 'Laufwerk G:', - typ: 'bluray', - pfad: '\\\\.\\G:', - status: 'ready', - modell: '', - seriennummer: '', - groesseBytes: 33e9, - grund: '', - }), + geraet: () => GERAET, ablage: () => ablage, einstellungen: () => ({}), melden: (s) => meldungen.push(s), jetzt: () => new Date('2026-08-30T12:00:00Z'), + vorgangEinreihen: (auftrag) => { + eingereiht.push(auftrag) + return eingereiht.length + }, + vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }), + nachRipAuswerfen: (id) => ausgeworfen.push(id), ...überschreiben, }) - return { pipeline, meldungen } + return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen } } -describe('Pipeline', () => { - it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } - encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } +const AUFTRAG: RipAuftragTitel[] = [ + { nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, + { nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 }, +] + +describe('Pipeline — die Rip-Phase (5.4.0)', () => { + it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => { + plan.schritte = [ + { status: 'success', datei: 'title_t00.mkv', bytes: 300 }, + { status: 'success', datei: 'title_t01.mkv', bytes: 100 }, + ] + const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung() + const beginn: string[] = [] + ;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner) + await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 }) + + expect(meldungen[0].phase).toBe('rippt') + const letzte = meldungen.at(-1)! + expect(letzte.phase).toBe('fertig') + expect(letzte.text).toContain('eingereiht') + expect(letzte.text).toContain('Laufwerk ist frei') + expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00') + expect(pipeline.laeuft('G')).toBe(false) + expect(ausgeworfen).toEqual(['G']) + expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')]) + + expect(eingereiht).toHaveLength(1) + const auftrag = eingereiht[0] + expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00') + expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge']) + expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn']) + expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu']) + expect(auftrag.dateien[1].audioSprachen).toEqual([]) + expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100]) + expect(auftrag.staffel).toBe(2) + expect(auftrag.ersteFolge).toBe(3) + expect(auftrag.preset).toBe('') + }) + + it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => { + plan.schritte = [ + { status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] }, + { status: 'success', datei: 'title_t01.mkv', ticks: [50] }, + ] const { pipeline, meldungen } = umgebung() - await pipeline.starten('G') - const phasen = meldungen.map((m) => m.phase) - expect(phasen[0]).toBe('rippt') - expect(phasen).toContain('komprimiert') - expect(phasen).toContain('legt-ab') - expect(phasen.at(-1)).toBe('fertig') - expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv']) + await pipeline.starten('G', AUFTRAG) + const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent) + // Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %, + // erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0. + expect(prozente).toContain(37) + expect(prozente).toContain(75) + expect(prozente).toContain(87) + for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1]) }) - it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } - const { pipeline, meldungen } = umgebung({ - einstellungen: () => ({ transcodePresetBluray: 'keine' }), - }) - await pipeline.starten('G') - const letzte = meldungen.at(-1) - expect(letzte?.phase).toBe('fertig') - expect(letzte?.text).toContain('abgewählt') - expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv']) + it('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => { + plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }] + const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung() + await pipeline.starten('G', AUFTRAG) + const letzte = meldungen.at(-1)! + expect(letzte.phase).toBe('abgebrochen') + expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv') + expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false) + expect(eingereiht).toHaveLength(0) + expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }]) }) - it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } - const { pipeline, meldungen } = umgebung({ handbrake: () => '' }) - await pipeline.starten('G') - expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden') + it('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => { + plan.schritte = [ + { status: 'success', datei: 'title_t00.mkv' }, + { status: 'cancelled', datei: 'title_t01.mkv' }, + ] + const { pipeline, meldungen, ablage } = umgebung() + await pipeline.starten('G', AUFTRAG) + expect(meldungen.at(-1)!.phase).toBe('abgebrochen') + const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00') + expect(readdirSync(roh)).toEqual(['title_t00.mkv']) + expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben') }) - it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } - encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' } - const { pipeline, meldungen } = umgebung() - await pipeline.starten('G') - const letzte = meldungen.at(-1) - expect(letzte?.phase).toBe('fehler') - expect(letzte?.text).toContain('bleibt stehen') - expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv']) + it('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => { + plan.schritte = [ + { status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' }, + { status: 'success', datei: 'title_t01.mkv' }, + ] + const { pipeline, meldungen, eingereiht } = umgebung() + await pipeline.starten('G', AUFTRAG) + const letzte = meldungen.at(-1)! + expect(letzte.phase).toBe('fertig') + expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv']) + expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert') + expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.mkv') }) - it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true } - encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } - const { pipeline, meldungen } = umgebung() + it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => { + plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }] + const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung() + await pipeline.starten('G', AUFTRAG) + expect(meldungen.at(-1)!.phase).toBe('fehler') + expect(meldungen.at(-1)!.text).toContain('kaputt') + expect(eingereiht).toHaveLength(0) + expect(endet[0].phase).toBe('fehler') + expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false) + }) + + it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => { + plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }] + const { pipeline, meldungen, eingereiht } = umgebung() + await pipeline.starten('G', [AUFTRAG[0]]) + expect(meldungen.at(-1)!.lesefehler).toBe(true) + expect(meldungen.at(-1)!.text).toContain('nicht lesen') + expect(eingereiht[0].lesefehler).toBe(true) + }) + + it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => { + plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] + const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) }) await pipeline.starten('G') - const letzte = meldungen.at(-1) - expect(letzte?.lesefehler).toBe(true) - expect(letzte?.text).toContain('nicht lesen') + expect(eingereiht[0].dateien).toHaveLength(1) + expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm') + expect(eingereiht[0].staffel).toBe(3) + expect(eingereiht[0].discNr).toBe(2) + }) + + it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => { + plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }] + const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined }) + await pipeline.starten('G') + expect(meldungen.at(-1)!.text).toContain('nicht eingereiht') }) it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => { const { pipeline, meldungen } = umgebung({ - geraet: () => ({ - id: 'G', - name: 'Laufwerk G:', - typ: 'unknown', - pfad: '\\\\.\\G:', - status: 'empty', - modell: '', - seriennummer: '', - groesseBytes: 0, - grund: '', - }), + geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }), }) await pipeline.starten('G') expect(meldungen[0].phase).toBe('fehler') expect(meldungen[0].text).toContain('Kein Datenträger') }) - it('NFO landet im fertigen Ordner', async () => { - ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } - encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } - const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-')) - wegwerf.push(ablage) - const meldungen: RipStatus[] = [] - const pipeline = new Pipeline({ - makemkvcon: () => 'x', - handbrake: () => 'y', - flac: () => '', - geraet: () => ({ - id: 'G', - name: 'Laufwerk G:', - typ: 'dvd', - pfad: '\\\\.\\G:', - status: 'ready', - modell: '', - seriennummer: '', - groesseBytes: 4e9, - grund: '', - }), - ablage: () => ablage, - einstellungen: () => ({}), - melden: (s) => meldungen.push(s), - jetzt: () => new Date('2026-08-30T12:00:00Z'), - }) - await pipeline.starten('G') - expect(meldungen.at(-1)?.phase).toBe('fertig') - const { existsSync, readdirSync } = await import('node:fs') - const filme = join(ablage, 'Filme') - const ordner = readdirSync(filme) - expect(ordner).toHaveLength(1) - expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true) + it('halbeDateienEntfernen: nur, was NEU dazukam', () => { + const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-')) + wegwerf.push(ordner) + writeFileSync(join(ordner, 'alt.mkv'), 'a') + const vorher = new Set(readdirSync(ordner)) + writeFileSync(join(ordner, 'neu.mkv'), 'b') + expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv']) + expect(readdirSync(ordner)).toEqual(['alt.mkv']) }) }) diff --git a/rippy-windows/test/rohaufraeumen.test.ts b/rippy-windows/test/rohaufraeumen.test.ts new file mode 100644 index 0000000..918f606 --- /dev/null +++ b/rippy-windows/test/rohaufraeumen.test.ts @@ -0,0 +1,148 @@ +// Tests des Roh-Aufräumens (kern/ablage/rohaufraeumen.ts, 5.4.0): die +// Regel, die Frist, die drei Sicherungen — und dass fremde Ordner nur +// gezeigt, nie von selbst gelöscht werden. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + aufraeumen, + faelligAm, + fremdeOrdner, + istUnterRohWurzel, + loeschHindernis, + ordnerGroesse, + regelLesen, + regelText, + type AufraeumKandidat, + type AufraeumZugriff, +} from '../src/kern/ablage/rohaufraeumen' + +let wegwerf: string[] = [] +afterEach(() => { + for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true }) +}) + +describe('regelLesen', () => { + it('kennt behalten, sofort und Tage — und die Vorgabe 7', () => { + expect(regelLesen('behalten')).toEqual({ art: 'behalten' }) + expect(regelLesen('sofort')).toEqual({ art: 'sofort' }) + expect(regelLesen('30')).toEqual({ art: 'tage', tage: 30 }) + expect(regelLesen(null)).toEqual({ art: 'tage', tage: 7 }) + expect(regelLesen('')).toEqual({ art: 'tage', tage: 7 }) + expect(regelLesen('quatsch')).toEqual({ art: 'tage', tage: 7 }) + expect(regelLesen('-3')).toEqual({ art: 'tage', tage: 7 }) + expect(regelText(regelLesen('7'))).toContain('7 Tage') + }) +}) + +describe('faelligAm', () => { + it('Tage nach dem Ende; sofort = am Ende; behalten = nie', () => { + expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'tage', tage: 7 })).toBe('2026-09-08T20:00:00.000Z') + expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'sofort' })).toBe('2026-09-01T20:00:00.000Z') + expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'behalten' })).toBe('') + expect(faelligAm(null, { art: 'sofort' })).toBe('') + expect(faelligAm('kaputt', { art: 'sofort' })).toBe('') + }) +}) + +const fertigeDateien = ['E:\\Rippy\\Filme\\Akira (1988)\\title_t00.mkv'] +const kandidat = (teil: Partial = {}): AufraeumKandidat => ({ + id: 'bluray-1', + phase: 'fertig', + komprimiert: true, + rohOrdner: 'E:\\Rippy\\roh\\bluray-1', + rohGeloeschtAm: null, + beendet: '2026-09-01T20:00:00.000Z', + fertigeDateien, + ...teil, +}) +const zugriff = (überschreiben: Partial = {}): AufraeumZugriff & { geloescht: string[] } => { + const geloescht: string[] = [] + return { + existiert: () => true, + groesse: () => 1000, + ordnerLoeschen: (p) => { + geloescht.push(p) + }, + geloescht, + ...überschreiben, + } +} +const regel7 = { art: 'tage' as const, tage: 7 } +const spaeter = new Date('2026-09-10T00:00:00Z') +const frueher = new Date('2026-09-03T00:00:00Z') + +describe('loeschHindernis — die drei Sicherungen', () => { + it('fertig, komprimiert, Ergebnis da, Frist um: löschbar', () => { + expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff())).toBe('') + }) + it('vor der Frist: nicht löschbar, mit Datum', () => { + expect(loeschHindernis(kandidat(), regel7, frueher, zugriff())).toContain('Frist läuft bis 2026-09-08') + }) + it('nicht fertig / nicht komprimiert / schon gelöscht / Regel behalten', () => { + expect(loeschHindernis(kandidat({ phase: 'fehler' }), regel7, spaeter, zugriff())).toContain('nicht fertig') + expect(loeschHindernis(kandidat({ komprimiert: false }), regel7, spaeter, zugriff())).toContain('nicht komprimiert') + expect(loeschHindernis(kandidat({ rohGeloeschtAm: 'x' }), regel7, spaeter, zugriff())).toBe('schon gelöscht') + expect(loeschHindernis(kandidat(), { art: 'behalten' }, spaeter, zugriff())).toBe('Regel: behalten') + }) + it('die fertige Datei fehlt oder ist leer: NIE löschen', () => { + expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ existiert: (p) => p.endsWith('bluray-1') }))).toContain('fertige Datei fehlt') + expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ groesse: () => 0 }))).toContain('ist leer') + expect(loeschHindernis(kandidat({ fertigeDateien: [] }), regel7, spaeter, zugriff())).toContain('keine fertige Datei') + }) + it('sofort: gleich nach dem Ende löschbar', () => { + expect(loeschHindernis(kandidat(), { art: 'sofort' }, new Date('2026-09-01T20:00:01Z'), zugriff())).toBe('') + }) +}) + +describe('aufraeumen', () => { + it('löscht nur die Fälligen und sagt, warum der Rest blieb', () => { + const z = zugriff() + const ergebnis = aufraeumen( + [kandidat(), kandidat({ id: 'jung', beendet: '2026-09-09T00:00:00.000Z' }), kandidat({ id: 'roh', komprimiert: false })], + regel7, + spaeter, + z, + ) + expect(ergebnis.geloescht).toEqual(['bluray-1']) + expect(z.geloescht).toEqual(['E:\\Rippy\\roh\\bluray-1']) + expect(ergebnis.uebersprungen.map((u) => u.id)).toEqual(['jung', 'roh']) + expect(ergebnis.fehler).toEqual([]) + }) + it('ein Ordner, der sich nicht löschen lässt, ist ein lauter Fehler, kein stilles Nichts', () => { + const ergebnis = aufraeumen([kandidat()], regel7, spaeter, zugriff({ ordnerLoeschen: () => { throw new Error('EBUSY') } })) + expect(ergebnis.geloescht).toEqual([]) + expect(ergebnis.fehler[0].grund).toContain('EBUSY') + }) +}) + +describe('Belegung und fremde Ordner — am echten Dateisystem', () => { + it('ordnerGroesse zählt rekursiv; fremdeOrdner kennt die Vorgänge und lässt sie aus', () => { + const wurzel = mkdtempSync(join(tmpdir(), 'rippy-roh-')) + wegwerf.push(wurzel) + const roh = join(wurzel, 'roh') + mkdirSync(join(roh, 'bluray-1', 'tief'), { recursive: true }) + mkdirSync(join(roh, 'alt-vor-540'), { recursive: true }) + writeFileSync(join(roh, 'bluray-1', 'a.mkv'), 'x'.repeat(100)) + writeFileSync(join(roh, 'bluray-1', 'tief', 'b.mkv'), 'x'.repeat(50)) + writeFileSync(join(roh, 'alt-vor-540', 'c.mkv'), 'x'.repeat(7)) + writeFileSync(join(roh, 'lose.txt'), 'x') + expect(ordnerGroesse(join(roh, 'bluray-1'))).toBe(150) + expect(ordnerGroesse(join(roh, 'gibt-es-nicht'))).toBe(0) + const fremde = fremdeOrdner(roh, new Set([join(roh, 'bluray-1')])) + expect(fremde).toEqual([{ ordner: join(roh, 'alt-vor-540'), bytes: 7 }]) + expect(fremdeOrdner(join(wurzel, 'nix'), new Set())).toEqual([]) + }) +}) + +describe('istUnterRohWurzel — nur Ordner DIREKT unter roh\\', () => { + it('erlaubt genau eine Ebene', () => { + expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\bluray-1')).toBe(true) + expect(istUnterRohWurzel('E:\\Rippy\\roh\\', 'e:\\rippy\\roh\\bluray-1\\')).toBe(true) + expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh')).toBe(false) + expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\a\\b')).toBe(false) + expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\Filme\\Akira')).toBe(false) + expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\rohling\\x')).toBe(false) + }) +}) diff --git a/rippy-windows/test/scan.test.ts b/rippy-windows/test/scan.test.ts new file mode 100644 index 0000000..5a1d6d6 --- /dev/null +++ b/rippy-windows/test/scan.test.ts @@ -0,0 +1,62 @@ +// Tests des HandBrake-Scans (kern/komprimieren/scan.ts, 5.4.0) — am +// gemessenen Format: „JSON Title Set:" + Objekt + weiterer Text. +import { describe, expect, it } from 'vitest' +import { buildScanArgs, fehlendeSprachen, spurenAusScan } from '../src/kern/komprimieren/scan' +import { regelAusQuery, RIPPY_REGEL } from '../src/kern/werkzeuge/spurauswahl' + +const AUSGABE = `[21:27:54] hb_init: starting libhb thread +Scanning title 1 of 1, preview 1, 25.00 % +JSON Title Set: { +"MainFeature": 0, +"TitleList": [ +{ +"AudioList": [ +{ "Description": "\u65e5\u672c\u8a9e (AAC LC, 2.0 ch)", "Language": "\u65e5\u672c\u8a9e", "LanguageCode": "jpn", "TrackNumber": 1 }, +{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }, +{ "Description": "English (AAC LC, 2.0 ch)", "Language": "English", "LanguageCode": "eng", "TrackNumber": 3 } +], +"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 }, +"Index": 1, +"Name": "quelle", +"SubtitleList": [ +{ "Format": "text", "Language": "Deutsch (UTF-8)", "LanguageCode": "deu", "SourceName": "UTF-8", "TrackNumber": 1 }, +{ "Format": "text", "Language": "English (UTF-8)", "LanguageCode": "eng", "SourceName": "UTF-8", "TrackNumber": 2 } +] +} +] +} +HandBrake has exited. +` + +describe('spurenAusScan', () => { + it('schneidet das Objekt aus dem Textprotokoll und liest Spuren + Dauer', () => { + const s = spurenAusScan(AUSGABE)! + expect(s.audio.map((a) => `${a.nr}:${a.lang}`)).toEqual(['1:jpn', '2:deu', '3:eng']) + expect(s.untertitel.map((u) => u.lang)).toEqual(['deu', 'eng']) + expect(s.dauerS).toBe(52 * 60 + 47) + }) + it('ohne Marke oder mit kaputtem JSON: null, nie ein leerer Befund', () => { + expect(spurenAusScan('nichts')).toBeNull() + expect(spurenAusScan('JSON Title Set: { "TitleList": [ ')).toBeNull() + expect(spurenAusScan('JSON Title Set: { "TitleList": [] }')).toBeNull() + }) + it('buildScanArgs: --scan --json, Titel 1', () => { + expect(buildScanArgs('R:\\roh\\a.mkv')).toEqual(['--scan', '--json', '--input', 'R:\\roh\\a.mkv', '--title', '1']) + }) +}) + +describe('fehlendeSprachen', () => { + it('nennt, was gewünscht ist und nicht drin', () => { + const s = spurenAusScan(AUSGABE)! + expect(fehlendeSprachen(s, { audio: ['jpn', 'fra'], untertitel: ['deu', 'jpn'] })).toEqual({ audio: ['fra'], untertitel: ['jpn'] }) + expect(fehlendeSprachen(s, { audio: [], untertitel: [] })).toEqual({ audio: [], untertitel: [] }) + }) +}) + +describe('MakeMKV-Auswahlregel — reg query lesen', () => { + it('findet den Wert in der Ausgabe', () => { + const ausgabe = `\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_DefaultSelectionString REG_SZ ${RIPPY_REGEL}\r\n\r\n` + expect(regelAusQuery(ausgabe)).toBe(RIPPY_REGEL) + expect(regelAusQuery('FEHLER: Der angegebene Registrierungsschlüssel oder Wert wurde nicht gefunden.')).toBeNull() + }) +}) diff --git a/rippy-windows/test/warteschlange.test.ts b/rippy-windows/test/warteschlange.test.ts new file mode 100644 index 0000000..d439af0 --- /dev/null +++ b/rippy-windows/test/warteschlange.test.ts @@ -0,0 +1,132 @@ +// Tests der Kompressions-Warteschlange (kern/ablauf/warteschlange.ts, +// 5.4.0): eine zur Zeit, der Reihe nach; abbrechen wartend und laufend; +// eine Ausnahme im Encode beendet nicht die Schlange; kein Doppel. +import { describe, expect, it } from 'vitest' +import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag' +import type { AblageErgebnis } from '../src/kern/ablauf/ablegen' +import { KompressionsWarteschlange, type WarteschlangenStand } from '../src/kern/ablauf/warteschlange' + +function auftrag(id: string): KompressionsAuftrag { + return { + vorgangId: id, + laufwerk: 'G', + discTyp: 'bluray', + rohOrdner: `R:\\roh\\${id}`, + dateien: [], + zuordnung: null, + staffel: null, + ersteFolge: null, + discNr: null, + fingerprint: null, + lesefehler: false, + hinweise: [], + begonnen: '2026-08-30T12:00:00Z', + preset: '', + zielOrdner: '', + } +} + +function fertig(text = 'ok'): AblageErgebnis { + return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] } +} + +/** Ein Encode, den der Test von außen beendet. */ +function steuerbar() { + const freigaben: Array<() => void> = [] + const gestartet: string[] = [] + const ausfuehren = (a: KompressionsAuftrag, melden: (s: { phase: 'komprimiert'; prozent: number; text: string }) => void, abbruch: AbortSignal) => + new Promise((loesen) => { + gestartet.push(a.vorgangId) + melden({ phase: 'komprimiert', prozent: 10, text: 'läuft' }) + abbruch.addEventListener('abort', () => loesen({ ...fertig('abgebrochen'), status: 'abgebrochen' }), { once: true }) + freigaben.push(() => loesen(fertig())) + }) + return { ausfuehren, freigaben, gestartet } +} + +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe('KompressionsWarteschlange', () => { + it('arbeitet der Reihe nach, meldet Stand und Ergebnis, vergibt Plätze', async () => { + const { ausfuehren, freigaben, gestartet } = steuerbar() + const staende: WarteschlangenStand[] = [] + const ergebnisse: string[] = [] + const q = new KompressionsWarteschlange(ausfuehren, (s) => staende.push(s), (a, e) => { + ergebnisse.push(`${a.vorgangId}:${e.status}`) + }) + expect(q.einreihen(auftrag('a'), 'A')).toBe(1) + expect(q.einreihen(auftrag('b'), 'B')).toBe(2) + expect(q.einreihen(auftrag('c'), 'C')).toBe(3) + await tick() + expect(gestartet).toEqual(['a']) + expect(q.stand().laeuft?.vorgangId).toBe('a') + expect(q.stand().laeuft?.prozent).toBe(10) + expect(q.stand().wartend.map((w) => w.vorgangId)).toEqual(['b', 'c']) + expect(q.laeuftEtwas()).toBe(true) + expect(q.kennt('b')).toBe(true) + + freigaben[0]() + await tick() + expect(gestartet).toEqual(['a', 'b']) + expect(ergebnisse).toEqual(['a:fertig']) + freigaben[1]() + await tick() + freigaben[2]() + await tick() + expect(ergebnisse).toEqual(['a:fertig', 'b:fertig', 'c:fertig']) + expect(q.laeuftEtwas()).toBe(false) + expect(q.stand().laeuft).toBeNull() + expect(staende.length).toBeGreaterThan(3) + }) + + it('kein Doppel: derselbe Vorgang bekommt seinen alten Platz zurück', async () => { + const { ausfuehren } = steuerbar() + const q = new KompressionsWarteschlange(ausfuehren, () => {}, () => {}) + q.einreihen(auftrag('a'), 'A') + q.einreihen(auftrag('b'), 'B') + await tick() + expect(q.einreihen(auftrag('a'), 'A')).toBe(1) + expect(q.einreihen(auftrag('b'), 'B')).toBe(2) + expect(q.stand().wartend).toHaveLength(1) + }) + + it('abbrechen: wartend wird entfernt (mit Ergebnis), laufend bekommt das Signal', async () => { + const { ausfuehren, freigaben } = steuerbar() + const ergebnisse: string[] = [] + const q = new KompressionsWarteschlange(ausfuehren, () => {}, (a, e) => { + ergebnisse.push(`${a.vorgangId}:${e.status}`) + }) + q.einreihen(auftrag('a'), 'A') + q.einreihen(auftrag('b'), 'B') + await tick() + expect(q.abbrechen('b')).toBe(true) + expect(ergebnisse).toEqual(['b:abgebrochen']) + expect(q.stand().wartend).toHaveLength(0) + expect(q.abbrechen('a')).toBe(true) + await tick() + expect(ergebnisse).toEqual(['b:abgebrochen', 'a:abgebrochen']) + expect(q.abbrechen('gibt-es-nicht')).toBe(false) + expect(freigaben).toHaveLength(1) + }) + + it('eine Ausnahme im Encode ist ein lautes Fehler-Ergebnis — die Schlange läuft weiter', async () => { + let n = 0 + const ergebnisse: string[] = [] + const q = new KompressionsWarteschlange( + async () => { + n += 1 + if (n === 1) throw new Error('HandBrake explodiert') + return fertig() + }, + () => {}, + (a, e) => { + ergebnisse.push(`${a.vorgangId}:${e.status}:${e.text.includes('explodiert')}`) + }, + ) + q.einreihen(auftrag('a'), 'A') + q.einreihen(auftrag('b'), 'B') + await tick() + await tick() + expect(ergebnisse).toEqual(['a:fehler:true', 'b:fertig:false']) + }) +})