// backend\generated_gc.go package main import ( "fmt" "io/fs" "os" "path/filepath" "strings" "sync/atomic" "time" ) var generatedGCRunning int32 // Startet den GC im Hintergrund, aber nur wenn nicht schon einer läuft. func triggerGeneratedGarbageCollectorAsync() { if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) { return } go func() { defer atomic.StoreInt32(&generatedGCRunning, 0) runGeneratedGarbageCollector() // ohne Sleep }() } // Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Ordner, für die es kein Video in /done mehr gibt. func startGeneratedGarbageCollector() { time.Sleep(3 * time.Second) runGeneratedGarbageCollector() } // Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup) func runGeneratedGarbageCollector() { s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { fmt.Println("🧹 [gc] resolve doneDir failed:", err) return } doneAbs = strings.TrimSpace(doneAbs) if doneAbs == "" { return } // 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren live := make(map[string]struct{}, 4096) _ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } name := d.Name() if d.IsDir() { if strings.EqualFold(name, ".trash") { return fs.SkipDir } return nil } ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return nil } info, err := d.Info() if err != nil || info.IsDir() || info.Size() <= 0 { return nil } base := strings.TrimSuffix(name, ext) id, err := sanitizeID(stripHotPrefix(base)) if err != nil || id == "" { return nil } live[id] = struct{}{} return nil }) // 2) /generated/meta/ prüfen metaRoot, err := generatedMetaRoot() if err == nil { metaRoot = strings.TrimSpace(metaRoot) } if err != nil || metaRoot == "" { return } removedMeta := 0 checkedMeta := 0 if entries, err := os.ReadDir(metaRoot); err == nil { for _, e := range entries { if !e.IsDir() { continue } id := strings.TrimSpace(e.Name()) if id == "" || strings.HasPrefix(id, ".") { continue } checkedMeta++ if _, ok := live[id]; ok { continue } removeGeneratedForID(id) removedMeta++ } } fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta) // 3) Optional: legacy /generated/ genRoot, err := generatedRoot() if err == nil { genRoot = strings.TrimSpace(genRoot) } if err != nil || genRoot == "" { return } reserved := map[string]struct{}{ "meta": {}, "covers": {}, "cover": {}, "temp": {}, "tmp": {}, ".trash": {}, } removedLegacy := 0 checkedLegacy := 0 if entries, err := os.ReadDir(genRoot); err == nil { for _, e := range entries { if !e.IsDir() { continue } name := strings.TrimSpace(e.Name()) if name == "" || strings.HasPrefix(name, ".") { continue } if _, ok := reserved[strings.ToLower(name)]; ok { continue } checkedLegacy++ if _, ok := live[name]; ok { continue } removeGeneratedForID(name) removedLegacy++ } } if checkedLegacy > 0 || removedLegacy > 0 { fmt.Printf("🧹 [gc] generated legacy checked=%d removed_orphans=%d\n", checkedLegacy, removedLegacy) } }