// backend\tasks_cleanup.go package main import ( "encoding/json" "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "sync/atomic" "time" ) type cleanupCandidate struct { path string name string size int64 } type cleanupResp struct { // Small downloads cleanup ScannedFiles int `json:"scannedFiles"` DeletedFiles int `json:"deletedFiles"` SkippedFiles int `json:"skippedFiles"` DeletedBytes int64 `json:"deletedBytes"` DeletedBytesHuman string `json:"deletedBytesHuman"` ErrorCount int `json:"errorCount"` // Generated-GC GeneratedOrphansChecked int `json:"generatedOrphansChecked"` GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"` GeneratedOrphansRemovedBytes int64 `json:"generatedOrphansRemovedBytes"` GeneratedOrphansRemovedBytesHuman string `json:"generatedOrphansRemovedBytesHuman"` } // Optional: falls du später Threshold per Body überschreiben willst. // Frontend sendet aktuell nichts -> wir nutzen Settings. type cleanupReq struct { BelowMB *int `json:"belowMB,omitempty"` } func clearCleanupTaskState() { cleanupTaskMu.Lock() cleanupTaskState = CleanupTaskState{} cleanupTaskMu.Unlock() publishTaskState() } // /api/settings/cleanup (POST) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - räumt Orphans (preview/thumbs + generated) auf func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } s := getSettings() // doneDir auflösen doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { setCleanupTaskError("doneDir auflösung fehlgeschlagen") time.AfterFunc(7*time.Second, func() { clearCleanupTaskState() }) http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) return } // Threshold: standardmäßig aus Settings mb := int(s.AutoDeleteSmallDownloadsBelowMB) // optional Body-Override (wenn du es später brauchst) // (Frontend sendet aktuell nichts; ist trotzdem safe) var req cleanupReq if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } if req.BelowMB != nil { mb = *req.BelowMB } if mb < 0 { mb = 0 } // ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind if err := acquireExclusiveTask(r.Context()); err != nil { http.Error(w, err.Error(), http.StatusRequestTimeout) return } defer releaseExclusiveTask() setCleanupTaskRunning("Räume auf…") resp := cleanupResp{} // 1) Kleine Downloads löschen (wenn mb > 0) if mb > 0 { threshold := int64(mb) * 1024 * 1024 cleanupSmallFiles(doneAbs, threshold, &resp) } // ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen, // damit die Zahlen in der JSON-Response landen. gcStats := triggerGeneratedGarbageCollectorSync() resp.GeneratedOrphansChecked = gcStats.Checked resp.GeneratedOrphansRemoved = gcStats.Removed resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) // ✅ Generated-GC zur insgesamt freigegebenen Größe addieren resp.DeletedBytes += gcStats.RemovedBytes resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) setCleanupTaskDone( cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), ) time.AfterFunc(7*time.Second, func() { clearCleanupTaskState() }) writeJSON(w, http.StatusOK, resp) } func cleanupProgressText(deleted int, total int, freedBytes int64) string { return fmt.Sprintf( "gelöscht: %d/%d · freigegeben: %s", deleted, total, formatBytesSI(freedBytes), ) } func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { isCandidate := func(name string) bool { low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { return false } ext := strings.ToLower(filepath.Ext(name)) return ext == ".mp4" || ext == ".ts" } candidates := make([]cleanupCandidate, 0, 1024) collectCandidates := func(dir string, allowSubdirs bool) { ents, err := os.ReadDir(dir) if err != nil { return } for _, e := range ents { full := filepath.Join(dir, e.Name()) if e.IsDir() { if !allowSubdirs { continue } if e.Name() == "keep" { continue } sub, err := os.ReadDir(full) if err != nil { continue } for _, se := range sub { if se.IsDir() { continue } name := se.Name() if !isCandidate(name) { resp.SkippedFiles++ continue } p := filepath.Join(full, name) fi, err := os.Stat(p) if err != nil || fi.IsDir() || fi.Size() <= 0 { resp.SkippedFiles++ continue } candidates = append(candidates, cleanupCandidate{ path: p, name: name, size: fi.Size(), }) } continue } name := e.Name() if !isCandidate(name) { resp.SkippedFiles++ continue } fi, err := os.Stat(full) if err != nil || fi.IsDir() || fi.Size() <= 0 { resp.SkippedFiles++ continue } candidates = append(candidates, cleanupCandidate{ path: full, name: name, size: fi.Size(), }) } } collectCandidates(doneAbs, true) resp.ScannedFiles = len(candidates) setCleanupTaskProgress( 0, resp.ScannedFiles, "", cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), ) for i, c := range candidates { if c.size < threshold { base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) id := stripHotPrefix(base) if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { resp.DeletedFiles++ resp.DeletedBytes += c.size if strings.TrimSpace(id) != "" { removeGeneratedForID(id) } purgeDurationCacheForPath(c.path) } else { resp.ErrorCount++ } } setCleanupTaskProgress( i+1, resp.ScannedFiles, "", cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), ) } } var generatedGCRunning int32 type generatedGCStats struct { Checked int Removed int RemovedBytes int64 } func dirSizeBytes(root string) int64 { root = strings.TrimSpace(root) if root == "" { return 0 } var total int64 _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return nil } if info.Size() > 0 { total += info.Size() } return nil }) return total } // 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 }) // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. // Sonst räumt der GC generated/meta/ weg, obwohl RecordStream dort noch schreibt. jobsMu.RLock() for _, j := range jobs { if j == nil { continue } id := strings.TrimSpace(assetIDForJob(j)) if id == "" { continue } live[id] = struct{}{} } jobsMu.RUnlock() // 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 removedBytes := int64(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 } orphanDir := filepath.Join(metaRoot, id) orphanBytes := dirSizeBytes(orphanDir) removeGeneratedForID(id) if _, err := os.Stat(orphanDir); os.IsNotExist(err) { removedMeta++ removedBytes += orphanBytes } } } //fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d bytes=%d\n", checkedMeta, removedMeta, removedBytes) stats.Checked += checkedMeta stats.Removed += removedMeta stats.RemovedBytes += removedBytes return stats }