// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. // EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über // AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und // hängen damit an der Prozess-Leine (R6). // // Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein // Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in // dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der // Einstellung im UI (W-5) eine Stimme. import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' // Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. import { basename, join } from 'node:path' import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' import { baueNfo } from '../ablage/nfo' import { bibliothekRefresh } from '../ablage/medienserver' import { posterSpeichern } from '../ablage/poster' import { zielordner } from '../ablage/struktur' import { komprimieren } from '../komprimieren/handbrake' import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' import type { Zuordnung } from '../metadaten/zuordnung' import { rippen } from '../rip/makemkv' 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 /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ zuordnung?: (id: string) => Zuordnung | null /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ fingerprint?: (id: string) => string | null /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */ bibliothekEintragen?: (eintrag: BibliothekEintrag) => void /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ jetzt?: () => Date } export class Pipeline { private laufend = new Map() constructor(private readonly umgebung: PipelineUmgebung) {} laeuft(id: string): boolean { return this.laufend.has(id) } private melden(status: RipStatus): void { const eintrag = this.laufend.get(status.id) if (eintrag !== undefined) eintrag.status = status this.umgebung.melden(status) } private fehler(id: string, text: string): RipStatus { return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } } /** 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 startZeit = this.umgebung.jetzt?.() ?? new Date() const zeit = startZeit.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) // Mit Metadaten (W-4) heißt der Ordner „ (Jahr)" — daran // erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname. const zuordnung = this.umgebung.zuordnung?.(id) ?? null const fertigOrdner = zuordnung !== null && zuordnung.titel.length > 0 ? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName) : zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName) mkdirSync(fertigOrdner, { recursive: true }) 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 { const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo' writeFileSync( join(fertigOrdner, nfoName), baueNfo( { typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film', beschreibung: zuordnung?.meta.beschreibung, genres: zuordnung?.meta.genres, laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', }, zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, zuordnung?.jahr ?? null, ), ) } catch (fehler) { // Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es // wird GESAGT (R4). const stand = this.laufend.get(id)?.status if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` }) } if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) } const server = String(einstellungen['mediaServer'] ?? '') const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') const refresh = await bibliothekRefresh(server, serverUrl, serverKey) // § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt. const fingerprint = this.umgebung.fingerprint?.(id) ?? null if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { let groesse = 0 for (const datei of fertigeDateien) { try { groesse += statSync(datei).size } catch { // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts // am Eintrag selbst. } } this.umgebung.bibliothekEintragen({ fingerprint, titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, jahr: zuordnung?.jahr ?? null, typ: geraet.typ, groesseBytes: groesse, ablageort: fertigOrdner, datum: startZeit.toISOString(), dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), }) } 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) }