Files
rippy/rippy-windows/src/kern/ablauf/pipeline.ts
T
HitonabiandClaude Fable 5 c78ff22ab7
Ampel / ampel (push) Successful in 1m23s
feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter-
laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt /
fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt
(setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC.
Eigenes Icon (bau/icon.png + tray.png, Disc-Optik).

Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7,
Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene
HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 —
fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim
Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast),
Pipeline traegt fertige Vorgaenge ein.

Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek).
Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ
(Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren'
waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand
mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle.

Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das
Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in
'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext
samt Abhilfe statt eines stummen Zustands.

165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:47:39 +02:00

323 lines
12 KiB
TypeScript

// 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<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 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 „<Titel> (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)
}