feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d70a81d194
commit
3f7f962dff
@@ -0,0 +1,198 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user