WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge, Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen: Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben. Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet. Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck, bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline, Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware. WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte Kompression waren seine Wahl). GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg- Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format, TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength, MakeMKV-Auswahlregel laut forum.makemkv.com t=4386. 348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche folgt im naechsten Schritt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
// 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' | '<Tage>' → 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<string>): 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)
|
|
}
|