// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange, // das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem // Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die // Benachrichtigung, wenn eine Kompression zu Ende ist. import { existsSync, rmSync } from 'node:fs' import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten' import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen' import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag' import type { VorgangBeginn } from '../ablauf/pipeline' import { KompressionsWarteschlange } from '../ablauf/warteschlange' import { AUFBEWAHRUNG_SCHLUESSEL, aufraeumen, faelligAm, fremdeOrdner, istUnterRohWurzel, ordnerGroesse, regelLesen, regelText, } from '../ablage/rohaufraeumen' import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung' import type { KernKontext } from '../kontext' import { TmdbClient } from '../metadaten/tmdb' import type { Vorgang } from '../speicher/db' import type { BenachrichtigungsDienst } from './benachrichtigung' import type { EinstellungsDienst } from './einstellungen' import type { ErkennungDienst } from './erkennung' import type { WerkzeugDienst } from './werkzeuge' /** Statistik aus den Vorgängen (pur). */ export function statistikAus(liste: readonly Pick[]): Statistik { let fertige = 0 let rohBytes = 0 let fertigBytes = 0 let dauerS = 0 for (const v of liste) { rohBytes += v.rohBytes if (v.phase !== 'fertig') continue fertige += 1 fertigBytes += v.fertigBytes if (v.beendet !== null) { const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000 if (Number.isFinite(d) && d > 0) dauerS += d } } return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) } } export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] { const liste: string[] = [] try { const roh: unknown = JSON.parse(text) if (Array.isArray(roh)) liste.push(...roh.map(String)) } catch (fehler) { // R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine // fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst. melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`) } return liste } export class VorgangDienst { readonly warteschlange: KompressionsWarteschlange /** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */ private letztePhase = new Map() constructor( private readonly kontext: KernKontext, private readonly einstellungen: EinstellungsDienst, private readonly werkzeuge: WerkzeugDienst, private readonly erkennung: ErkennungDienst, private readonly benachrichtigung: BenachrichtigungsDienst, /** Nach Änderungen an Vorgängen auch den Speicher neu messen. */ private readonly speicherSenden: () => void, ) { this.warteschlange = new KompressionsWarteschlange( (auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch), (stand) => this.standGeaendert(stand.laeuft), (auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis), ) } private get db() { return this.kontext.db } ablageUmgebung(): AblageUmgebung { return { handbrake: () => this.werkzeuge.handbrake(), ablage: () => this.einstellungen.ablageOrdner(), arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(), einstellungen: () => this.einstellungen.fuerPipeline(), presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets), // Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2). episodenLaufzeiten: async (tmdbId, staffel) => { const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey')) if (!tmdb.verfuegbar) return [] const antwort = await tmdb.tvStaffel(tmdbId, staffel) const paare: Array<[number, number]> = [] for (const ep of antwort?.episodes ?? []) { if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60]) } return paare }, bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag), } } bibliothekEintragen(eintrag: Parameters>[0]): void { try { this.db?.bibliothekEintragen(eintrag) } catch (fehler) { // R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt. this.kontext.fehler(`Bibliothek: ${String(fehler)}`) } } private standGeaendert(laeuft: KompressionStand | null): void { if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) { this.letztePhase.set(laeuft.vorgangId, laeuft.phase) this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' }) } this.senden() } private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise { this.letztePhase.delete(auftrag.vorgangId) const jetzt = new Date().toISOString() this.db?.vorgangAktualisieren(auftrag.vorgangId, { phase: ergebnis.status, beendet: jetzt, zielOrdner: ergebnis.zielOrdner, fertigBytes: ergebnis.fertigBytes, komprimiert: ergebnis.komprimiert, preset: ergebnis.preset, fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text, fertigeDateien: JSON.stringify(ergebnis.dateien), }) const vorgang = this.db?.vorgangLesen(auftrag.vorgangId) const titel = vorgang?.titel ?? auftrag.vorgangId const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '') this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status} — ${text}`) this.kontext.anHauptUndFenster({ art: 'kompression-ergebnis', vorgangId: auftrag.vorgangId, titel, ok: ergebnis.status === 'fertig', text, }) // Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter? const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) { const letzte = Math.max(...ergebnis.folgenVergeben) this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1) } if (ergebnis.status !== 'abgebrochen') { void this.benachrichtigung.senden( ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen', `${titel}: ${ergebnis.text.slice(0, 300)}`, ) } // Regel „sofort": direkt nach der gelungenen Kompression aufräumen. if (ergebnis.status === 'fertig') this.rohAufraeumenLauf() else this.senden() } // ── Pipeline-Haken ────────────────────────────────────────────────────── vorgangBeginnt(v: VorgangBeginn): void { const vorgang: Vorgang = { id: v.id, laufwerk: v.laufwerk, typ: v.typ, titel: v.titel, jahr: v.jahr, phase: 'rippt', begonnen: v.begonnen, beendet: null, rohOrdner: v.rohOrdner, rohBytes: 0, rohGeloeschtAm: null, zielOrdner: '', fertigBytes: 0, komprimiert: false, preset: '', fehler: '', auftrag: '{}', lesefehler: false, fertigeDateien: '[]', } this.db?.vorgangAnlegen(vorgang) this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk} → ${v.rohOrdner}`) this.senden() } vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number { this.db?.vorgangAktualisieren(auftrag.vorgangId, { phase: 'wartet', titel, jahr: auftrag.zuordnung?.jahr ?? null, rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0), lesefehler: auftrag.lesefehler, auftrag: auftragZuJson(auftrag), }) const platz = this.warteschlange.einreihen(auftrag, titel) this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`) this.senden() return platz } vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void { this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text }) this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`) this.senden() } // ── Stand fürs Fenster ────────────────────────────────────────────────── stand(): VorgaengeStand { const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, '')) const liste = this.db?.vorgangListe() ?? [] let rohGesamtBytes = 0 const vorgaenge: VorgangEintrag[] = liste.map((v) => { const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner) const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0 rohGesamtBytes += rohBytes const auftrag = auftragAusJson(v.auftrag) const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen' return { id: v.id, laufwerk: v.laufwerk, typ: v.typ, titel: v.titel, jahr: v.jahr, phase: v.phase, begonnen: v.begonnen, beendet: v.beendet, rohOrdner: v.rohOrdner, rohBytes, rohGeloescht: !rohDa, zielOrdner: v.zielOrdner, fertigBytes: v.fertigBytes, komprimiert: v.komprimiert, preset: v.preset, fehler: v.fehler, titelZahl: auftrag?.dateien.length ?? 0, neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id), faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '', } }) const bekannte = new Set(liste.map((v) => v.rohOrdner)) const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte) for (const f of fremde) rohGesamtBytes += f.bytes return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() }, statistik: statistikAus(liste), } } senden(): void { try { this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() }) } catch (fehler) { this.kontext.fehler(`Vorgänge: ${String(fehler)}`) } this.speicherSenden() } // ── Pause (5.5.0) ─────────────────────────────────────────────────────── pause(an: boolean): void { this.warteschlange.pausieren(an) this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.') this.senden() } // ── Roh-Aufräumen ─────────────────────────────────────────────────────── rohAufraeumenLauf(): void { if (this.db === null) return try { const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, '')) const kandidaten = this.db.vorgangListe().map((v) => ({ id: v.id, phase: v.phase, komprimiert: v.komprimiert, rohOrdner: v.rohOrdner, rohGeloeschtAm: v.rohGeloeschtAm, beendet: v.beendet, fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)), })) const ergebnis = aufraeumen(kandidaten, regel, new Date()) const jetzt = new Date().toISOString() for (const id of ergebnis.geloescht) { this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 }) this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`) } for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`) } catch (fehler) { this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`) } this.senden() } rohLoeschen(vorgangId: string): void { const v = this.db?.vorgangLesen(vorgangId) if (v === null || v === undefined) { this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`) return } if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) { this.kontext.fehler(`„${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`) return } // Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen // (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner // direkt unter irgendeinem roh\ ist. if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) { this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`) return } try { rmSync(v.rohOrdner, { recursive: true, force: true }) this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() }) this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`) } catch (fehler) { this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`) } this.senden() } fremdenOrdnerLoeschen(ordner: string): void { if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) { this.kontext.fehler(`„${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`) return } const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase()) if (bekannt) { this.kontext.fehler(`„${ordner}" gehört zu einem Vorgang — dort löschen.`) return } try { rmSync(ordner, { recursive: true, force: true }) this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`) } catch (fehler) { this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`) } this.senden() } /** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */ neuKomprimieren(vorgangId: string, preset: string): void { const v = this.db?.vorgangLesen(vorgangId) if (v === null || v === undefined) { this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`) return } if (this.warteschlange.kennt(vorgangId)) { this.kontext.fehler(`„${v.titel}" ist schon in der Warteschlange.`) return } const auftrag = auftragAusJson(v.auftrag) if (auftrag === null) { this.kontext.fehler(`„${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`) return } const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad)) if (vorhandene.length === 0) { this.kontext.fehler(`„${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`) return } if (vorhandene.length < auftrag.dateien.length) { this.kontext.hinweis(`„${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`) } const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) const neu: KompressionsAuftrag = { ...auftrag, dateien: vorhandene, preset, // Die neue Fassung ersetzt die alte im selben Ordner — die alten // Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts). zielOrdner: alteFassung ? v.zielOrdner : '', ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [], } this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) }) const platz = this.warteschlange.einreihen(neu, v.titel) this.kontext.hinweis(`„${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`) this.senden() } /** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */ wiederaufnehmen(): void { if (this.db === null) return const jetzt = new Date().toISOString() for (const v of this.db.vorgangListe()) { if (v.phase === 'rippt') { this.db.vorgangAktualisieren(v.id, { phase: 'abgebrochen', beendet: jetzt, fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.', }) this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`) continue } if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') { const auftrag = auftragAusJson(v.auftrag) if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) { this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel) this.kontext.hinweis(`„${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`) } else { this.db.vorgangAktualisieren(v.id, { phase: 'fehler', beendet: jetzt, fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.', }) } } } } }