// backend\generated_gc.go package main import ( "fmt" "io/fs" "os" "path/filepath" "strings" "sync/atomic" "time" ) var generatedGCRunning int32 type generatedGCStats struct { Checked int Removed int } // Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response). func triggerGeneratedGarbageCollectorSync() generatedGCStats { // nur 1 GC gleichzeitig if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) { fmt.Println("🧹 [gc] skip: already running") return generatedGCStats{} } defer atomic.StoreInt32(&generatedGCRunning, 0) stats := runGeneratedGarbageCollector() return stats } // Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Orphans. func startGeneratedGarbageCollector() { go func() { time.Sleep(3 * time.Second) triggerGeneratedGarbageCollectorSync() }() } // Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup) // Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann. func runGeneratedGarbageCollector() generatedGCStats { stats := generatedGCStats{} s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { fmt.Println("🧹 [gc] resolve doneDir failed:", err) return stats } doneAbs = strings.TrimSpace(doneAbs) if doneAbs == "" { return stats } // 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 stats } 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) stats.Checked += checkedMeta stats.Removed += removedMeta // 3) Optional: legacy /generated/ genRoot, err := generatedRoot() if err == nil { genRoot = strings.TrimSpace(genRoot) } if err != nil || genRoot == "" { return stats } 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) } stats.Checked += checkedLegacy stats.Removed += removedLegacy return stats }