From 3dc296f07148a297d052d464777e87648bfb666e Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 18:26:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(v5):=20Etappe=20W-3=20=E2=80=94=20Komprimi?= =?UTF-8?q?eren=20+=20Ablegen,=20mit=20Vollbeweis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions- Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN), --format aus der Endung (Container kommt sonst aus dem Preset), Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12 Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen (kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help, --preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ, PRESET_KEINE, Sprachlisten. ablage/: struktur.ts (sichere Namen, ' (Jahr)', Season NN, Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema), medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie). ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI). GEMESSEN am echten System (30.08.2026): - Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1 (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets. - Probe-Encode 5 s Material: success in 45,5 s. - VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11 am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt: E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2 (2011)\ mit movie.nfo. - Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen und es nicht sind (der abgebrochene rc11-Rip). Co-Authored-By: Claude Fable 5 --- rippy-windows/src/fenster/App.tsx | 4 +- rippy-windows/src/gemeinsam/nachrichten.ts | 9 +- rippy-windows/src/kern/ablage/medienserver.ts | 46 +++ rippy-windows/src/kern/ablage/nfo.ts | 46 +++ rippy-windows/src/kern/ablage/struktur.ts | 134 +++++++++ rippy-windows/src/kern/ablauf/pipeline.ts | 267 ++++++++++++++++++ rippy-windows/src/kern/ablauf/rippen.ts | 151 ---------- rippy-windows/src/kern/index.ts | 30 +- .../src/kern/komprimieren/encoder.ts | 101 +++++++ rippy-windows/test/ablage.test.ts | 171 +++++++++++ rippy-windows/test/handbrake.test.ts | 184 ++++++++++++ .../test/messung.encode-voll.test.ts | 81 ++++++ rippy-windows/test/messung.handbrake.test.ts | 63 +++++ rippy-windows/test/pipeline.test.ts | 168 +++++++++++ 14 files changed, 1300 insertions(+), 155 deletions(-) create mode 100644 rippy-windows/src/kern/ablage/medienserver.ts create mode 100644 rippy-windows/src/kern/ablage/nfo.ts create mode 100644 rippy-windows/src/kern/ablage/struktur.ts create mode 100644 rippy-windows/src/kern/ablauf/pipeline.ts delete mode 100644 rippy-windows/src/kern/ablauf/rippen.ts create mode 100644 rippy-windows/src/kern/komprimieren/encoder.ts create mode 100644 rippy-windows/test/ablage.test.ts create mode 100644 rippy-windows/test/handbrake.test.ts create mode 100644 rippy-windows/test/messung.encode-voll.test.ts create mode 100644 rippy-windows/test/messung.handbrake.test.ts create mode 100644 rippy-windows/test/pipeline.test.ts diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index eb8e2dc..ea5db19 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -58,8 +58,10 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) { ) } +const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'] + function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) { - const ripLaeuft = rip !== undefined && rip.phase === 'rippt' + const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase) const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler' const punktFarbe = ripLaeuft ? 'animate-pulse bg-sky-400' diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 1371253..c70d9f7 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -39,7 +39,14 @@ export type WacheEreignis = export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin' // ── Rippen (Etappe W-2) ───────────────────────────────────────────────── -export type RipPhase = 'liest-info' | 'rippt' | 'fertig' | 'fehler' | 'abgebrochen' +export type RipPhase = + | 'liest-info' + | 'rippt' + | 'komprimiert' + | 'legt-ab' + | 'fertig' + | 'fehler' + | 'abgebrochen' export interface RipStatus { /** Laufwerks-Kennung, auf der der Rip läuft. */ diff --git a/rippy-windows/src/kern/ablage/medienserver.ts b/rippy-windows/src/kern/ablage/medienserver.ts new file mode 100644 index 0000000..0ea0d41 --- /dev/null +++ b/rippy-windows/src/kern/ablage/medienserver.ts @@ -0,0 +1,46 @@ +// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) — +// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit +// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth +// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort, +// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET). + +export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none' + +export async function bibliothekRefresh( + server: string, + url: string, + apiKey: string, + holen: typeof fetch = fetch, +): Promise { + if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return '' + try { + if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) { + const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', { + method: 'POST', + headers: { 'X-Emby-Token': apiKey }, + signal: AbortSignal.timeout(15_000), + }) + if (antwort.status < 300) { + return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})` + } + return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}` + } + if (server === 'kodi') { + const kopf: Record = { 'Content-Type': 'application/json' } + if (apiKey.includes(':')) { + kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64') + } + const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', { + method: 'POST', + headers: kopf, + body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }), + signal: AbortSignal.timeout(15_000), + }) + if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})` + return `Kodi-Refresh: HTTP ${antwort.status} von ${url}` + } + } catch (fehler) { + return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}` + } + return '' +} diff --git a/rippy-windows/src/kern/ablage/nfo.ts b/rippy-windows/src/kern/ablage/nfo.ts new file mode 100644 index 0000000..810046e --- /dev/null +++ b/rippy-windows/src/kern/ablage/nfo.ts @@ -0,0 +1,46 @@ +// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) — +// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung +// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py. + +export const NFO_SERVER = ['jellyfin', 'emby', 'kodi'] + +export interface NfoMeta { + typ?: 'film' | 'serie' + beschreibung?: string + genres?: readonly string[] + laufzeitMinuten?: number + quelle?: string +} + +function xml(wert: string): string { + return wert + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +/** Minimales NFO (pur, testbar). */ +export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string { + const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie' + const zeilen = [ + '', + `<${wurzel}>`, + ` ${xml(titel ?? '')}`, + ] + if (jahr !== undefined && jahr !== null && String(jahr).length > 0) { + zeilen.push(` ${xml(String(jahr))}`) + } + if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) { + zeilen.push(` ${xml(meta.beschreibung)}`) + } + for (const genre of meta.genres ?? []) { + zeilen.push(` ${xml(String(genre))}`) + } + if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) { + zeilen.push(` ${xml(String(meta.laufzeitMinuten))}`) + } + zeilen.push(`
Source: ${xml(meta.quelle ?? 'Rippy')}
`) + zeilen.push(``) + return zeilen.join('\n') + '\n' +} diff --git a/rippy-windows/src/kern/ablage/struktur.ts b/rippy-windows/src/kern/ablage/struktur.ts new file mode 100644 index 0000000..deae812 --- /dev/null +++ b/rippy-windows/src/kern/ablage/struktur.ts @@ -0,0 +1,134 @@ +// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus +// medien.py: Ordner heißt „ (Jahr)" (daran erkennen die Server den +// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien +// liegen als /Season NN. Episoden werden NUR bei EINDEUTIGER +// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde +// #395 war genau das Ratespiel). +import { existsSync, readdirSync, renameSync } from 'node:fs' +import { win32 as pfad } from 'node:path' + +const { join } = pfad + +// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen. +// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen. +// eslint-disable-next-line no-control-regex +const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g + +/** Titel → dateisystem-tauglicher Ordnername (pur). */ +export function sichererName(titel: string, jahr?: number | string | null): string { + let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '') + name = name.replace(/\s+/g, ' ').slice(0, 150).trim() + if (name.length === 0) return '' + // Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen. + if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) { + name = `${name} (${jahr})` + } + return name +} + +/** Zielordner: sprechender Name; existiert er schon (Disc doppelt + * gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */ +export function zielordner( + basis: string, + titel: string, + jahr: number | string | null, + jobId: string, + existiert: (p: string) => boolean = existsSync, +): string { + const name = sichererName(titel, jahr) + if (name.length === 0) return join(basis, jobId) + const ziel = join(basis, name) + if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`) + return ziel +} + +/** Jellyfin-Konvention: //Season NN (pur). */ +export function serienOrdner(basis: string, serie: string, staffel: number): string { + const name = sichererName(serie) || 'Unbekannte Serie' + return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`) +} + +/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend. + * Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig. + * + * Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in + * Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten; + * passt eine Datei auf mehrere Episoden ähnlich gut → null. */ +export function matcheEpisoden( + dateiSekunden: readonly number[], + episoden: ReadonlyArray, + toleranzProzent = 8.0, + toleranzMinS = 180, +): number[] | null { + if (dateiSekunden.length === 0 || episoden.length === 0) return null + + const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100) + + if (dateiSekunden.length === episoden.length) { + const passt = dateiSekunden.every((dauer, i) => { + const epDauer = episoden[i][1] + return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer) + }) + if (passt) return episoden.map(([nr]) => nr) + } + + const zuordnung: number[] = [] + let start = 0 + for (const dauer of dateiSekunden) { + const kandidaten = episoden + .slice(start) + .filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)) + if (kandidaten.length === 0) return null + let gewaehlt = kandidaten[0] + if (kandidaten.length > 1) { + const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1])) + // Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah). + if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null + gewaehlt = sortiert[0] + } + zuordnung.push(gewaehlt[0]) + start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1 + } + return zuordnung +} + +/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge = + * sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */ +export function episodenUmbenennen( + ordner: string, + serie: string, + staffel: number, + zuordnung: readonly number[], + fs: { + listen: (o: string) => string[] + existiert: (p: string) => boolean + umbenennen: (von: string, nach: string) => void + } = { + listen: (o) => readdirSync(o), + existiert: existsSync, + umbenennen: renameSync, + }, +): string[] { + const meldungen: string[] = [] + const dateien = fs + .listen(ordner) + .filter((f) => f.endsWith('.mkv')) + .sort() + if (dateien.length !== zuordnung.length) { + return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`] + } + const serienName = sichererName(serie) || 'Serie' + for (let i = 0; i < dateien.length; i++) { + const alt = dateien[i] + const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv` + if (alt === neu) continue + const ziel = join(ordner, neu) + if (fs.existiert(ziel)) { + meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`) + continue + } + fs.umbenennen(join(ordner, alt), ziel) + meldungen.push(`${alt} → ${neu}`) + } + return meldungen +} diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts new file mode 100644 index 0000000..495b1f6 --- /dev/null +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -0,0 +1,267 @@ +// 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, writeFileSync } from 'node:fs' +import { win32 as pfad } from 'node:path' +import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' +import { baueNfo } from '../ablage/nfo' +import { bibliothekRefresh } from '../ablage/medienserver' +import { zielordner } from '../ablage/struktur' +import { komprimieren } from '../komprimieren/handbrake' +import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' +import { rippen } from '../rip/makemkv' + +const { basename, join } = pfad + +interface LaufenderVorgang { + abbruch: AbortController + 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 + /** 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 + /** 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 } + } + + /** Startet die Kette für die ganze Disc ('all'). */ + async starten(id: string): 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 zeit = (this.umgebung.jetzt?.() ?? new Date()).toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) + const vorgangsName = `${geraet.typ}-${zeit}` + const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) + + const start: RipStatus = { + id, + phase: 'rippt', + prozent: -1, + text: `Rippe verlustfrei nach ${rohOrdner} …`, + dateien: [], + lesefehler: false, + } + this.laufend.set(id, { abbruch, status: start }) + this.melden(start) + + const rip = await rippen({ + programm, + geraetepfad: geraet.pfad, + zielOrdner: rohOrdner, + titel: 'all', + abbruch: abbruch.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 }) + }, + }) + + if (rip.status === 'cancelled') { + this.laufend.delete(id) + this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) + return + } + if (rip.status === 'error') { + this.laufend.delete(id) + this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler }) + return + } + + const lesefehlerHinweis = rip.lesefehler + ? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' + : '' + + // ── 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) + const fertigOrdner = zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName) + mkdirSync(fertigOrdner, { recursive: true }) + + const fertigeDateien: string[] = [] + for (let i = 0; i < rip.dateien.length; i++) { + const eingabe = rip.dateien[i] + const ausgabe = join(fertigOrdner, 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) + } + + // ── 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 { + writeFileSync(join(fertigOrdner, 'movie.nfo'), baueNfo({ quelle: 'Rippy v5' }, vorgangsName, 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)}` }) + } + + const server = String(einstellungen['mediaServer'] ?? '') + const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') + const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') + const refresh = await bibliothekRefresh(server, serverUrl, serverKey) + + 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() + } +} + +// 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) +} diff --git a/rippy-windows/src/kern/ablauf/rippen.ts b/rippy-windows/src/kern/ablauf/rippen.ts deleted file mode 100644 index 9dc95b5..0000000 --- a/rippy-windows/src/kern/ablauf/rippen.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Rip-Verwaltung (W-2) — die schlanke Vorstufe der Pipeline aus § 4.2: -// EIN Rip je Laufwerk, Status-Meldungen nach draußen, Abbruch über -// AbortController. makemkvcon läuft als Kind dieses Kerns und hängt damit -// automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die -// Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die -// Mitgliedschaft. -import { win32 as pfad } from 'node:path' -import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' - -const { join } = pfad -import { rippen } from '../rip/makemkv' - -interface LaufenderRip { - abbruch: AbortController - status: RipStatus -} - -export interface RipUmgebung { - /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ - makemkvcon: () => string - /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ - geraet: (id: string) => GeraeteInfo | null - /** Wohin gerippt wird (Wurzel der Ablage). */ - ablage: () => string - melden: (status: RipStatus) => void - /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ - jetzt?: () => Date -} - -export class RipVerwaltung { - private laufend = new Map() - - constructor(private readonly umgebung: RipUmgebung) {} - - laeuft(id: string): boolean { - return this.laufend.has(id) - } - - status(id: string): RipStatus | null { - return this.laufend.get(id)?.status ?? null - } - - private melden(status: RipStatus): void { - const eintrag = this.laufend.get(status.id) - if (eintrag !== undefined) eintrag.status = status - this.umgebung.melden(status) - } - - /** Startet einen verlustfreien Rip der ganzen Disc ('all'). */ - async starten(id: string): Promise { - if (this.laufend.has(id)) { - this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Rip.`)) - 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 zeit = (this.umgebung.jetzt?.() ?? new Date()) - .toISOString() - .replaceAll(/[:.]/g, '-') - .slice(0, 19) - const zielOrdner = join(this.umgebung.ablage(), 'roh', `${geraet.typ}-${zeit}`) - - const start: RipStatus = { - id, - phase: 'rippt', - prozent: -1, - text: `Rippe verlustfrei nach ${zielOrdner} …`, - dateien: [], - lesefehler: false, - } - this.laufend.set(id, { abbruch, status: start }) - this.melden(start) - - const ergebnis = await rippen({ - programm, - geraetepfad: geraet.pfad, - zielOrdner, - titel: 'all', - abbruch: abbruch.signal, - aufFortschritt: (prozent) => { - const alt = this.laufend.get(id)?.status - if (alt === undefined) return - this.melden({ ...alt, prozent }) - }, - aufMeldung: (_code, text) => { - const alt = this.laufend.get(id)?.status - if (alt === undefined) return - this.melden({ ...alt, text }) - }, - }) - - this.laufend.delete(id) - if (ergebnis.status === 'success') { - this.melden({ - id, - phase: 'fertig', - prozent: 100, - // Lesefehler AUCH bei Erfolg nennen — genau dann gehen sie sonst - // unter (Rückgabewert 0, ein Titel fehlt trotzdem). - text: ergebnis.lesefehler - ? '⚠ Fertig, aber die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).' - : 'Fertig — verlustfrei gesichert.', - dateien: ergebnis.dateien, - lesefehler: ergebnis.lesefehler, - }) - } 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: ergebnis.lesefehler, - }) - } - } - - abbrechen(id: string): void { - this.laufend.get(id)?.abbruch.abort() - } - - private fehler(id: string, text: string): RipStatus { - return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } - } -} diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index bb30d34..3c0aea0 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -14,7 +14,7 @@ import { type DbStatus, type KernNachricht, } from '../gemeinsam/nachrichten' -import { RipVerwaltung } from './ablauf/rippen' +import { Pipeline } from './ablauf/pipeline' import type { LaufwerkApi } from './laufwerk/codes' import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc' import { DiscWache } from './laufwerk/wache' @@ -113,14 +113,40 @@ function ablageOrdner(): string { return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy') } -const ripVerwaltung = new RipVerwaltung({ +// 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. +const PIPELINE_EINSTELLUNGEN = [ + 'transcodeEnabled', + 'transcodePreset', + 'transcodePresetDvd', + 'transcodePresetBluray', + 'transcodePresetUhd', + 'audioSprachen', + 'untertitelSprachen', + 'mediaServer', + 'mediaServerUrl', + 'mediaServerApiKey', +] as const + +function einstellungenLaden(): Record { + const werte: Record = {} + for (const schluessel of PIPELINE_EINSTELLUNGEN) { + const wert = db?.einstellungLesen(schluessel) + if (wert !== null && wert !== undefined) werte[schluessel] = wert + } + return werte +} + +const ripVerwaltung = new Pipeline({ makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), + handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), geraet: (id) => { if (laufwerkApi === null) return null const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet) }, ablage: ablageOrdner, + einstellungen: einstellungenLaden, melden: (status) => anHauptUndFenster({ art: 'rip-status', status }), }) diff --git a/rippy-windows/src/kern/komprimieren/encoder.ts b/rippy-windows/src/kern/komprimieren/encoder.ts new file mode 100644 index 0000000..d0de57c --- /dev/null +++ b/rippy-windows/src/kern/komprimieren/encoder.ts @@ -0,0 +1,101 @@ +// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2, +// Regeln aus caps.py, dort am echten HandBrake abgenommen): +// +// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich +// benutzen kann (HandBrake probiert Hardware beim Start selbst an). +// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die +// Einrückung — Namen stehen mit genau vier Leerzeichen). +// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst +// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT). +import { spawn } from 'node:child_process' +import { textVon } from '../rip/parser' + +const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/ + +/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */ +export function parseEncoderListe(hilfeText: string): string[] { + const encoder: string[] = [] + let imBlock = false + for (const zeile of (hilfeText ?? '').split(/\r?\n/)) { + const inhalt = zeile.trim() + if (!imBlock) { + if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true + continue + } + if (inhalt.length === 0) continue + if (inhalt.startsWith('-')) break + encoder.push(inhalt.split(/\s+/)[0]) + } + return encoder +} + +// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen. +const HW_FAMILIEN: ReadonlyArray = [ + ['nvenc', 'nvenc'], + ['qsv', 'qsv'], + ['vce', 'vce'], + ['vaapi', 'vaapi'], +] + +/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */ +export function leiteBackendsAb(hbEncoder: readonly string[]): string[] { + const namen = new Set(hbEncoder.map((e) => e.toLowerCase())) + const gefunden: string[] = [] + if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264') + if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265') + if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1') + for (const [kennzeichen, backend] of HW_FAMILIEN) { + const treffer = [...namen].filter((n) => n.includes(kennzeichen)) + if (treffer.length === 0) continue + gefunden.push(backend) + if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`) + } + return gefunden +} + +/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung. + * Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */ +export function parsePresetListe(text: string): string[] { + const presets: string[] = [] + for (const zeile of (text ?? '').split(/\r?\n/)) { + if (zeile.trim().length === 0) continue + const einrueckung = zeile.length - zeile.replace(/^ +/, '').length + if (einrueckung === 4) presets.push(zeile.trim()) + } + return presets +} + +function lauf(programm: string, args: string[], timeoutMs: number): Promise { + return new Promise((fertig) => { + const kind = spawn(programm, args, { 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('') + }) + kind.on('close', () => { + clearTimeout(zeiger) + fertig(textVon(Buffer.concat(stuecke))) + }) + }) +} + +export interface EncoderAuskunft { + encoder: string[] + backends: string[] + presets: string[] +} + +/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */ +export async function encoderMessen(programm: string): Promise { + if (programm.length === 0) return { encoder: [], backends: [], presets: [] } + const [hilfe, presetListe] = await Promise.all([ + lauf(programm, ['--help'], 30_000), + lauf(programm, ['--preset-list'], 30_000), + ]) + const encoder = parseEncoderListe(hilfe) + return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) } +} diff --git a/rippy-windows/test/ablage.test.ts b/rippy-windows/test/ablage.test.ts new file mode 100644 index 0000000..6a799f3 --- /dev/null +++ b/rippy-windows/test/ablage.test.ts @@ -0,0 +1,171 @@ +// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO. +// Testfälle sinngemäß aus test_medien.py (Docker-Zweig). +import { describe, expect, it } from 'vitest' +import { baueNfo } from '../src/kern/ablage/nfo' +import { + episodenUmbenennen, + matcheEpisoden, + serienOrdner, + sichererName, + zielordner, +} from '../src/kern/ablage/struktur' +import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder' + +describe('sichererName', () => { + it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => { + expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe( + 'Der Herr der Ringe Die Gefährten (2001)', + ) + }) + it('kein doppeltes Jahr (der OMDb-Fund)', () => { + expect(sichererName('X (2009)', 2009)).toBe('X (2009)') + }) + it('leerer Titel bleibt leer, Punkte am Ende fallen', () => { + expect(sichererName('...')).toBe('') + expect(sichererName('Film.', null)).toBe('Film') + }) +}) + +describe('zielordner', () => { + it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => { + expect(zielordner('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe( + 'E:\\Filme\\Akira (1988)', + ) + expect(zielordner('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe( + 'E:\\Filme\\Akira (1988) [job-1234]', + ) + expect(zielordner('E:\\Filme', '', null, 'job-1', () => false)).toBe('E:\\Filme\\job-1') + }) +}) + +describe('serienOrdner', () => { + it('Jellyfin-Schema Serie/Season NN', () => { + expect(serienOrdner('E:\\Serien', 'Dr. Who?', 2)).toBe('E:\\Serien\\Dr. Who\\Season 02') + }) +}) + +describe('matcheEpisoden', () => { + it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => { + expect( + matcheEpisoden( + [1420, 1425, 1418], + [ + [1, 1420], + [2, 1420], + [3, 1420], + ], + ), + ).toEqual([1, 2, 3]) + }) + it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => { + expect( + matcheEpisoden( + [1200, 2400], + [ + [1, 1210], + [2, 2390], + [3, 3000], + ], + ), + ).toEqual([1, 2]) + }) + it('mehrdeutig heißt null — lieber gar nicht als falsch', () => { + expect( + matcheEpisoden( + [1420, 1421], + [ + [1, 1420], + [2, 1420], + [3, 1420], + ], + ), + ).toBeNull() + }) + it('keine Daten heißt null', () => { + expect(matcheEpisoden([], [[1, 100]])).toBeNull() + expect(matcheEpisoden([100], [])).toBeNull() + }) +}) + +describe('episodenUmbenennen', () => { + it('benennt in Disc-Reihenfolge um, überschreibt nie', () => { + const umbenannt: Array<[string, string]> = [] + const meldungen = episodenUmbenennen('E:\\S', 'Akte X', 1, [3, 4], { + listen: () => ['title_t01.mkv', 'title_t00.mkv'], + existiert: () => false, + umbenennen: (von, nach) => umbenannt.push([von, nach]), + }) + expect(umbenannt).toEqual([ + ['E:\\S\\title_t00.mkv', 'E:\\S\\Akte X S01E03.mkv'], + ['E:\\S\\title_t01.mkv', 'E:\\S\\Akte X S01E04.mkv'], + ]) + expect(meldungen).toHaveLength(2) + }) + it('Zahl passt nicht → gar nichts anfassen', () => { + const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], { + listen: () => ['a.mkv', 'b.mkv'], + existiert: () => false, + umbenennen: () => { + throw new Error('darf nicht') + }, + }) + expect(meldungen[0]).toContain('übersprungen') + }) +}) + +describe('baueNfo', () => { + it('movie.nfo mit XML-Escaping', () => { + const nfo = baueNfo( + { beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 }, + 'Akira ', + 1988, + ) + expect(nfo).toContain('') + expect(nfo).toContain('Akira <Director\'s Cut>') + expect(nfo).toContain('Roboter & Menschen') + expect(nfo).toContain('1988') + expect(nfo).toContain('124') + expect(nfo).toContain('Source: Rippy') + }) + it('Serie bekommt tvshow als Wurzel', () => { + expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('') + }) +}) + +// caps.py-Fixtures: das --help- und --preset-list-Format, am echten +// HandBrake gemessen (25./26.07.2026). +describe('Encoder- und Preset-Listen', () => { + it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => { + const hilfe = [ + ' -e, --encoder Select video encoder:', + ' svt_av1', + ' x264', + ' x265', + ' nvenc_h264', + ' --encoder-preset ', + ' ignored', + ].join('\n') + expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264']) + }) + it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => { + expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([ + 'cpu-x264', + 'cpu-x265', + 'cpu-av1', + 'vce', + 'vce-av1', + ]) + expect(leiteBackendsAb([])).toEqual([]) + }) + it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => { + const liste = [ + 'General/', + ' Very Fast 2160p60 4K AV1', + ' AV1 video (up to 2160p60) and AAC stereo audio.', + 'Hardware/', + ' H.265 VCN 2160p 4K', + '[10:26:05] qsv: not available on this system', + ].join('\n') + expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K']) + }) +}) diff --git a/rippy-windows/test/handbrake.test.ts b/rippy-windows/test/handbrake.test.ts new file mode 100644 index 0000000..2654abd --- /dev/null +++ b/rippy-windows/test/handbrake.test.ts @@ -0,0 +1,184 @@ +// Tests der HandBrake-Schicht — die Testfälle aus test_ripping/caps +// wandern sinngemäß mit. Der wichtigste ist eine Anti-Regression: Der +// alte Test FORDERTE den falschen Schalter ein (assert "--audio-codec" +// in cmd) und schrieb den Fehler damit fest, statt ihn zu finden. +import { describe, expect, it } from 'vitest' +import { + buildHandbrakeArgs, + dateiDaneben, + HbAuswertung, + hbSchluss, + HbZeilenLeser, + progressAusZeile, + unbekannterSchalter, + unbekanntesPreset, +} from '../src/kern/komprimieren/handbrake' +import { + komprimierenFuer, + presetFuer, + PRESET_KEINE, + sprachliste, + DEFAULT_HB_PRESET, +} from '../src/kern/komprimieren/presets' + +describe('buildHandbrakeArgs', () => { + const args = buildHandbrakeArgs({ + eingabe: 'E:\\roh\\film_t00.mkv', + ausgabe: 'E:\\fertig\\Film (2009).mkv', + preset: 'H.265 MKV 1080p30', + audioSprachen: ['deu', 'eng'], + untertitelSprachen: ['deu'], + }) + + it('nutzt --aencoder und NIEMALS --audio-codec (der 16,5-GB-Fund)', () => { + // HandBrake meldet einen unbekannten Schalter mit Rückgabewert 0 — + // dieser Wächter passt auf, dass der Fehler nie zurückkommt. + expect(args).toContain('--aencoder') + expect(args.join(' ')).not.toContain('--audio-codec') + const stelle = args.indexOf('--aencoder') + expect(args[stelle + 1]).toBe('copy') + expect(args).toContain('--audio-fallback') + }) + + it('erzwingt den Container aus der Endung (--format av_mkv)', () => { + const stelle = args.indexOf('--format') + expect(stelle).toBeGreaterThan(-1) + expect(args[stelle + 1]).toBe('av_mkv') + }) + + it('Sprachlisten und --all-subtitles/--first-audio', () => { + expect(args[args.indexOf('--audio-lang-list') + 1]).toBe('deu,eng') + expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu') + expect(args).toContain('--first-audio') + expect(args).toContain('--all-subtitles') + }) + + it('ohne Sprachwahl keine Listen-Schalter', () => { + const schlicht = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X' }) + expect(schlicht).not.toContain('--audio-lang-list') + expect(schlicht).not.toContain('--subtitle-lang-list') + }) +}) + +describe('progressAusZeile — nur die Encoding-Zeile zählt', () => { + it('liest den Encode-Fortschritt', () => { + expect(progressAusZeile('Encoding: task 1 of 1, 42.57 % (89.31 fps, avg 90.05 fps, ETA 00h12m34s)')).toBe(42) + }) + it('der Scan-Durchlauf zählt NICHT (die 99-%-Falle)', () => { + expect(progressAusZeile('Scanning title 1 of 1, preview 7, 70.00 %')).toBe(-1) + expect(progressAusZeile('Encoding: task 1 of 1, Searching for start time, 12.00')).toBe(-1) + }) + it('zwei Durchläufe werden zusammengerechnet', () => { + expect(progressAusZeile('Encoding: task 2 of 2, 50.00 %')).toBe(75) + }) + it('ein echtes 0 % ist eine Angabe', () => { + expect(progressAusZeile('Encoding: task 1 of 1, 0.87 %')).toBe(0) + }) +}) + +describe('Fehler-Erkennung', () => { + it('unbekanntes Preset (Wortlaut aus dem echten Lauf)', () => { + expect(unbekanntesPreset('Invalid preset Gibt Es Nicht 999p')).toBe('Gibt Es Nicht 999p') + expect(unbekanntesPreset('Valid presets are:')).toBe('') + }) + it('unbekannter Schalter (kommt mit Rückgabewert 0!)', () => { + expect(unbekannterSchalter('unknown option (--audio-codec)')).toBe('--audio-codec') + expect(unbekannterSchalter('Encoding: task 1 of 1, 1.00 %')).toBe('') + }) + it('hbSchluss hebt die letzten Worte auf', () => { + expect(hbSchluss(['a', '', 'b', 'c', 'd', 'e'])).toBe(' — HandBrake sagte zuletzt: b | c | d | e') + expect(hbSchluss([])).toBe('') + }) +}) + +describe('HbAuswertung.urteil — die bewiesene Reihenfolge', () => { + it('falscher Schalter schlägt ALLES, auch Code 0', () => { + const a = new HbAuswertung() + a.verarbeite('unknown option (--audio-codec)') + a.verarbeite('HandBrake has exited.') + const urteil = a.urteil(0, 'x.mkv', () => true) + expect(urteil.status).toBe('error') + if (urteil.status === 'error') { + expect(urteil.fehler).toContain('--audio-codec') + expect(urteil.fehler).toContain('Fehler in Rippy') + } + }) + + it('Code 0 + Datei da = Erfolg', () => { + const urteil = new HbAuswertung().urteil(0, 'x.mkv', (p) => p === 'x.mkv') + expect(urteil).toEqual({ status: 'success', ausgabe: 'x.mkv' }) + }) + + it('Code 0, Datei fehlt, liegt daneben → Erfolg MIT Hinweis (Container aus Preset)', () => { + const urteil = new HbAuswertung().urteil(0, 'E:\\ziel\\film.mkv', (p) => p === 'E:\\ziel\\film.mp4') + expect(urteil.status).toBe('success') + if (urteil.status === 'success') { + expect(urteil.ausgabe).toBe('E:\\ziel\\film.mp4') + expect(urteil.hinweis).toContain('Container') + } + }) + + it('Code 0 ohne Datei: Ordner wird GENANNT, letzte Worte zitiert', () => { + const a = new HbAuswertung() + a.verarbeite('No title found.') + const urteil = a.urteil(0, 'E:\\ziel\\film.mkv', () => false) + expect(urteil.status).toBe('error') + if (urteil.status === 'error') { + expect(urteil.fehler).toContain('E:\\ziel') + expect(urteil.fehler).toContain('No title found.') + } + }) + + it('unbekanntes Preset wird erklärt', () => { + const a = new HbAuswertung() + a.verarbeite('Invalid preset Uralt 480p') + const urteil = a.urteil(3, 'x.mkv', () => false) + expect(urteil.status).toBe('error') + if (urteil.status === 'error') expect(urteil.fehler).toContain('Uralt 480p') + }) +}) + +describe('dateiDaneben', () => { + it('findet die Datei mit anderer Endung im selben Ordner', () => { + expect(dateiDaneben('E:\\z\\film.mkv', (p) => p === 'E:\\z\\film.mp4')).toBe('E:\\z\\film.mp4') + expect(dateiDaneben('E:\\z\\film.mkv', () => false)).toBe('') + }) +}) + +describe('HbZeilenLeser — CR trennt den Fortschritt', () => { + it('CR, LF und CRLF trennen; halbe Zeilen bleiben liegen', () => { + const leser = new HbZeilenLeser() + expect(leser.hinzu(Buffer.from('Encoding: task 1 of 1, 1.00 %\rEncoding: task 1 of 1, 2.00'))).toEqual([ + 'Encoding: task 1 of 1, 1.00 %', + ]) + expect(leser.hinzu(Buffer.from(' %\r\nfertig\n'))).toEqual(['Encoding: task 1 of 1, 2.00 %', 'fertig']) + }) + it('das ü aus zwei Kodierungen tötet den Leser NICHT (0x81-Falle)', () => { + const leser = new HbZeilenLeser() + const zeilen = leser.hinzu( + Buffer.concat([Buffer.from('pfad Gl'), Buffer.from([0x81]), Buffer.from('ck.mkv\n')]), + ) + expect(zeilen).toHaveLength(1) + expect(zeilen[0]).toContain('pfad Gl') + }) +}) + +describe('presets', () => { + it('komprimierenFuer: globaler Schalter schlägt alles; PRESET_KEINE je Typ', () => { + expect(komprimierenFuer('uhd', { transcodeEnabled: false })).toBe(false) + expect(komprimierenFuer('uhd', { transcodePresetUhd: PRESET_KEINE })).toBe(false) + expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true) + expect(komprimierenFuer('bluray', {})).toBe(true) + }) + it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => { + expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p') + expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein') + expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET) + expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A') + }) + it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => { + expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng']) + expect(sprachliste(['ENG', 'eng'])).toEqual(['eng']) + expect(sprachliste(null)).toEqual([]) + }) +}) diff --git a/rippy-windows/test/messung.encode-voll.test.ts b/rippy-windows/test/messung.encode-voll.test.ts new file mode 100644 index 0000000..74286af --- /dev/null +++ b/rippy-windows/test/messung.encode-voll.test.ts @@ -0,0 +1,81 @@ +// DER W-3-BEWEIS: kompletter Encode einer echten Roh-MKV plus Ablage in +// echter Jellyfin-Struktur (Ordner „ (Jahr)" + movie.nfo). Läuft +// nur auf Verlangen und dauert je nach Material 20 min bis Stunden: +// +// RIPPY_MESSUNG_ENCODE=1 RIPPY_MESSUNG_MKV= \ +// RIPPY_MESSUNG_ABLAGE= RIPPY_MESSUNG_TITEL= \ +// RIPPY_MESSUNG_JAHR= [RIPPY_MESSUNG_PRESET=] \ +// npx vitest run test/messung.encode-voll.test.ts --reporter=verbose +import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { win32 as pfad } from 'node:path' +import { describe, expect, it } from 'vitest' + +const messen = process.env.RIPPY_MESSUNG_ENCODE === '1' && process.platform === 'win32' +const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? '' +const ablageBasis = process.env.RIPPY_MESSUNG_ABLAGE ?? '' +const titel = process.env.RIPPY_MESSUNG_TITEL ?? 'Unbenannt' +const jahr = process.env.RIPPY_MESSUNG_JAHR ?? '' +const preset = process.env.RIPPY_MESSUNG_PRESET ?? 'H.265 MKV 1080p30' + +describe.skipIf(!messen)('Voller Encode + Ablage am echten System', () => { + it('komprimiert und legt nach Jellyfin-Schema ab', { timeout: 8 * 60 * 60 * 1000 }, async () => { + expect(rohMkv.length, 'RIPPY_MESSUNG_MKV fehlt').toBeGreaterThan(0) + expect(existsSync(rohMkv), `${rohMkv} fehlt`).toBe(true) + expect(ablageBasis.length, 'RIPPY_MESSUNG_ABLAGE fehlt').toBeGreaterThan(0) + + const { finden } = await import('../src/kern/werkzeuge/katalog') + const { komprimieren } = await import('../src/kern/komprimieren/handbrake') + const { zielordner } = await import('../src/kern/ablage/struktur') + const { baueNfo } = await import('../src/kern/ablage/nfo') + + const programm = finden('handbrake') + expect(programm.length).toBeGreaterThan(0) + + const ordner = zielordner(ablageBasis, titel, jahr.length > 0 ? jahr : null, 'v5-beweis') + mkdirSync(ordner, { recursive: true }) + const ausgabe = pfad.join(ordner, pfad.basename(ordner) + '.mkv') + + console.log(`ENCODE-START ${rohMkv} → ${ausgabe} · Preset „${preset}"`) + const start = Date.now() + let letzteStufe = -5 + const ergebnis = await komprimieren({ + programm, + eingabe: rohMkv, + ausgabe, + preset, + audioSprachen: ['deu', 'eng'], + aufFortschritt: (prozent) => { + if (prozent >= letzteStufe + 5) { + letzteStufe = prozent + console.log(`ENCODE-FORTSCHRITT ${prozent}% nach ${((Date.now() - start) / 60000).toFixed(1)}min`) + } + }, + }) + const minuten = ((Date.now() - start) / 60000).toFixed(1) + + if (ergebnis.status !== 'success') { + console.log(`ENCODE-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`) + } + expect(ergebnis.status).toBe('success') + if (ergebnis.status !== 'success') return + + const rohGb = statSync(rohMkv).size / 1e9 + const fertigGb = statSync(ergebnis.ausgabe).size / 1e9 + console.log( + `ENCODE-ERGEBNIS success nach ${minuten}min · ${rohGb.toFixed(2)} GB → ${fertigGb.toFixed(2)} GB` + + (ergebnis.hinweis !== undefined ? ` · ${ergebnis.hinweis}` : ''), + ) + + const nfoPfad = pfad.join(ordner, 'movie.nfo') + writeFileSync(nfoPfad, baueNfo({ quelle: 'Rippy v5 W-3-Beweis' }, titel, jahr.length > 0 ? jahr : null)) + console.log(`ABLAGE-FERTIG ${ordner} (movie.nfo + ${pfad.basename(ergebnis.ausgabe)})`) + expect(existsSync(nfoPfad)).toBe(true) + expect(fertigGb).toBeGreaterThan(0.05) + }) +}) + +describe.skipIf(messen)('Encode-Messung übersprungen', () => { + it('läuft nur mit RIPPY_MESSUNG_ENCODE=1 unter Windows', () => { + expect(true).toBe(true) + }) +}) diff --git a/rippy-windows/test/messung.handbrake.test.ts b/rippy-windows/test/messung.handbrake.test.ts new file mode 100644 index 0000000..5a6f0bc --- /dev/null +++ b/rippy-windows/test/messung.handbrake.test.ts @@ -0,0 +1,63 @@ +// MESSUNG am echten HandBrakeCLI: Encoder-Auskunft und ein kurzer +// Probe-Encode an einer echten Roh-MKV. Läuft nur auf Verlangen: +// +// RIPPY_MESSUNG=1 [RIPPY_MESSUNG_MKV=] \ +// npx vitest run test/messung.handbrake.test.ts --reporter=verbose +import { existsSync, mkdtempSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32' +const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? '' + +describe.skipIf(!messen)('HandBrake am echten System', () => { + it('meldet Encoder, Backends und Presets', { timeout: 120_000 }, async () => { + const { finden } = await import('../src/kern/werkzeuge/katalog') + const { encoderMessen } = await import('../src/kern/komprimieren/encoder') + const programm = finden('handbrake') + expect(programm.length).toBeGreaterThan(0) + const auskunft = await encoderMessen(programm) + console.log(`MESSUNG Encoder (${auskunft.encoder.length}): ${auskunft.encoder.join(', ')}`) + console.log(`MESSUNG Backends: ${auskunft.backends.join(', ')}`) + console.log(`MESSUNG Presets: ${auskunft.presets.length} Stück, z. B. ${auskunft.presets.slice(0, 4).join(' | ')}`) + expect(auskunft.encoder.length).toBeGreaterThan(0) + expect(auskunft.presets.length).toBeGreaterThan(0) + }) + + it.skipIf(rohMkv.length === 0 || !existsSync(rohMkv))( + 'kurzer Probe-Encode an der echten Roh-MKV (5 s Material)', + { timeout: 15 * 60_000 }, + async () => { + const { finden } = await import('../src/kern/werkzeuge/katalog') + const { komprimieren } = await import('../src/kern/komprimieren/handbrake') + const programm = finden('handbrake') + const ziel = join(mkdtempSync(join(tmpdir(), 'rippy-hb-')), 'probe.mkv') + const start = Date.now() + const ergebnis = await komprimieren({ + programm, + eingabe: rohMkv, + ausgabe: ziel, + preset: 'H.265 MKV 1080p30', + audioSprachen: ['deu', 'jpn', 'eng'], + extraArgs: ['--start-at', 'duration:60', '--stop-at', 'duration:5'], + }) + const sekunden = ((Date.now() - start) / 1000).toFixed(1) + if (ergebnis.status === 'success') { + console.log( + `MESSUNG Probe-Encode: success in ${sekunden}s → ${ergebnis.ausgabe} (${(statSync(ergebnis.ausgabe).size / 1e6).toFixed(1)} MB)` + + (ergebnis.hinweis !== undefined ? ` · Hinweis: ${ergebnis.hinweis}` : ''), + ) + } else { + console.log(`MESSUNG Probe-Encode: ${ergebnis.status} in ${sekunden}s ${'fehler' in ergebnis ? ergebnis.fehler : ''}`) + } + expect(ergebnis.status).toBe('success') + }, + ) +}) + +describe.skipIf(messen)('Messung übersprungen', () => { + it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => { + expect(true).toBe(true) + }) +}) diff --git a/rippy-windows/test/pipeline.test.ts b/rippy-windows/test/pipeline.test.ts new file mode 100644 index 0000000..c5c4731 --- /dev/null +++ b/rippy-windows/test/pipeline.test.ts @@ -0,0 +1,168 @@ +// 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' +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' + +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, +})) + +vi.mock('../src/kern/rip/makemkv', () => ({ + rippen: vi.fn(async () => ripErgebnis.wert), +})) +vi.mock('../src/kern/komprimieren/handbrake', () => ({ + komprimieren: vi.fn(async () => encodeErgebnis.wert), +})) + +import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline' + +let wegwerf: string[] = [] +afterEach(() => { + for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true }) +}) + +function umgebung(überschreiben: Partial = {}): { + pipeline: Pipeline + meldungen: RipStatus[] +} { + const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-')) + wegwerf.push(ablage) + const meldungen: RipStatus[] = [] + const pipeline = new Pipeline({ + makemkvcon: () => 'C:\\mk\\makemkvcon64.exe', + handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', + geraet: () => ({ + id: 'G', + name: 'Laufwerk G:', + typ: 'bluray', + pfad: '\\\\.\\G:', + status: 'ready', + modell: '', + seriennummer: '', + groesseBytes: 33e9, + grund: '', + }), + ablage: () => ablage, + einstellungen: () => ({}), + melden: (s) => meldungen.push(s), + jetzt: () => new Date('2026-08-30T12:00:00Z'), + ...überschreiben, + }) + return { pipeline, meldungen } +} + +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 { 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']) + }) + + 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('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('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('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() + await pipeline.starten('G') + const letzte = meldungen.at(-1) + expect(letzte?.lesefehler).toBe(true) + expect(letzte?.text).toContain('nicht lesen') + }) + + 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: '', + }), + }) + 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', + 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) + }) +})