feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s

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, '<Titel> (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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:26:00 +02:00
co-authored by Claude Fable 5
parent ca338ad467
commit 3dc296f071
14 changed files with 1300 additions and 155 deletions
+3 -1
View File
@@ -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'
+8 -1
View File
@@ -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. */
@@ -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<string> {
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<string, string> = { '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 ''
}
+46
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** 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 = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
`<${wurzel}>`,
` <title>${xml(titel ?? '')}</title>`,
]
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
zeilen.push(` <year>${xml(String(jahr))}</year>`)
}
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
}
for (const genre of meta.genres ?? []) {
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
}
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
}
+134
View File
@@ -0,0 +1,134 @@
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
// liegen als <Serie>/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: <basis>/<Serie>/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<readonly [nummer: number, laufzeitS: number]>,
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
}
+267
View File
@@ -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<string, LaufenderVorgang>()
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<void> {
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)
}
-151
View File
@@ -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<string, LaufenderRip>()
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<void> {
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 }
}
}
+28 -2
View File
@@ -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<string, string> {
const werte: Record<string, string> = {}
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 }),
})
@@ -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<readonly [string, string]> = [
['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<string> {
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<EncoderAuskunft> {
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) }
}
+171
View File
@@ -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 <Director\'s Cut>',
1988,
)
expect(nfo).toContain('<movie>')
expect(nfo).toContain('<title>Akira &lt;Director\'s Cut&gt;</title>')
expect(nfo).toContain('<plot>Roboter &amp; Menschen</plot>')
expect(nfo).toContain('<year>1988</year>')
expect(nfo).toContain('<runtime>124</runtime>')
expect(nfo).toContain('Source: Rippy')
})
it('Serie bekommt tvshow als Wurzel', () => {
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
})
})
// 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 <string> Select video encoder:',
' svt_av1',
' x264',
' x265',
' nvenc_h264',
' --encoder-preset <string>',
' 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'])
})
})
+184
View File
@@ -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([])
})
})
@@ -0,0 +1,81 @@
// DER W-3-BEWEIS: kompletter Encode einer echten Roh-MKV plus Ablage in
// echter Jellyfin-Struktur (Ordner „<Titel> (Jahr)" + movie.nfo). Läuft
// nur auf Verlangen und dauert je nach Material 20 min bis Stunden:
//
// RIPPY_MESSUNG_ENCODE=1 RIPPY_MESSUNG_MKV=<roh.mkv> \
// RIPPY_MESSUNG_ABLAGE=<basis> RIPPY_MESSUNG_TITEL=<titel> \
// RIPPY_MESSUNG_JAHR=<jahr> [RIPPY_MESSUNG_PRESET=<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)
})
})
@@ -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=<roh.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)
})
})
+168
View File
@@ -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<PipelineUmgebung> = {}): {
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)
})
})