feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
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:
co-authored by
Claude Fable 5
parent
ca338ad467
commit
3dc296f071
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user