Ampel / ampel (push) Successful in 1m23s
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>
323 lines
12 KiB
TypeScript
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)
|
|
}
|