// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe. // // Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte // auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im // UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders // (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression". // // Drei Sicherungen, alle bewusst: // 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und // KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben // worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen. // 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht // leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige // Weg, wie Rippy Daten verlieren könnte. // 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0), // werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers. import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' // Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux. import { join } from 'node:path' import type { FremderOrdner } from '../../gemeinsam/nachrichten' export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number } /** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */ export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung' export const STANDARD_AUFBEWAHRUNG = '7' /** 'behalten' | 'sofort' | '' → Regel. Unbekanntes = Vorgabe. */ export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel { const text = (wert ?? '').trim().toLowerCase() if (text === 'behalten' || text === 'nie') return { art: 'behalten' } if (text === 'sofort' || text === '0') return { art: 'sofort' } const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG) if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage } return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) } } export function regelText(regel: AufbewahrungsRegel): string { if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.' if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.' return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.` } /** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */ export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string { if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return '' const beendet = new Date(beendetIso) if (Number.isNaN(beendet.getTime())) return '' if (regel.art === 'sofort') return beendet.toISOString() return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString() } /** Das, was das Aufräumen über einen Vorgang wissen muss. */ export interface AufraeumKandidat { id: string phase: string komprimiert: boolean rohOrdner: string rohGeloeschtAm: string | null beendet: string | null fertigeDateien: readonly string[] } export interface AufraeumZugriff { existiert: (pfad: string) => boolean /** Dateigröße in Bytes; 0 wenn nicht lesbar. */ groesse: (pfad: string) => number ordnerLoeschen: (pfad: string) => void } export const ECHTER_ZUGRIFF: AufraeumZugriff = { existiert: existsSync, groesse: (pfad) => { try { return statSync(pfad).size } catch { // Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht. return 0 } }, ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }), } /** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */ export function loeschHindernis( v: AufraeumKandidat, regel: AufbewahrungsRegel, jetzt: Date, zugriff: AufraeumZugriff = ECHTER_ZUGRIFF, ): string { if (v.rohGeloeschtAm !== null) return 'schon gelöscht' if (v.phase !== 'fertig') return `nicht fertig (${v.phase})` if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis' if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg' if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt' for (const datei of v.fertigeDateien) { if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}` if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}` } if (regel.art === 'behalten') return 'Regel: behalten' const frist = faelligAm(v.beendet, regel) if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt' if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}` return '' } export interface AufraeumErgebnis { geloescht: string[] uebersprungen: Array<{ id: string; grund: string }> fehler: Array<{ id: string; grund: string }> } /** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */ export function aufraeumen( vorgaenge: readonly AufraeumKandidat[], regel: AufbewahrungsRegel, jetzt: Date, zugriff: AufraeumZugriff = ECHTER_ZUGRIFF, ): AufraeumErgebnis { const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] } for (const v of vorgaenge) { const hindernis = loeschHindernis(v, regel, jetzt, zugriff) if (hindernis.length > 0) { ergebnis.uebersprungen.push({ id: v.id, grund: hindernis }) continue } try { zugriff.ordnerLoeschen(v.rohOrdner) ergebnis.geloescht.push(v.id) } catch (fehler) { // R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird // gesagt — beim nächsten Takt kommt er wieder dran. ergebnis.fehler.push({ id: v.id, grund: String(fehler) }) } } return ergebnis } /** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */ export function ordnerGroesse(pfad: string): number { let summe = 0 const gehen = (ordner: string): void => { let eintraege: import('node:fs').Dirent[] try { eintraege = readdirSync(ordner, { withFileTypes: true }) } catch { // Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie // erfunden. return } for (const e of eintraege) { const voll = join(ordner, e.name) if (e.isDirectory()) gehen(voll) else { try { summe += statSync(voll).size } catch { // siehe oben } } } } if (existsSync(pfad)) gehen(pfad) return summe } /** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */ export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet): FremderOrdner[] { if (!existsSync(rohWurzel)) return [] const liste: FremderOrdner[] = [] let namen: string[] try { namen = readdirSync(rohWurzel, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) } catch (fehler) { // R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste // bleibt dann leer, weil wirklich nichts gesehen wurde. console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`) return liste } for (const name of namen) { const voll = join(rohWurzel, name) if (bekannte.has(voll) || bekannte.has(name)) continue liste.push({ ordner: voll, bytes: ordnerGroesse(voll) }) } return liste.sort((a, b) => b.bytes - a.bytes) } /** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche * darf „fremden Ordner löschen" anfassen — nie etwas anderes. */ export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean { const wurzel = rohWurzel.replace(/[\\/]+$/, '') const kandidat = pfad.replace(/[\\/]+$/, '') if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false const rest = kandidat.slice(wurzel.length) return /^[\\/][^\\/]+$/.test(rest) }