// backend\cleanup.go package main import ( "encoding/json" "net/http" "os" "path/filepath" "strings" ) 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"` // Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei) OrphanIDsScanned int `json:"orphanIdsScanned"` OrphanIDsRemoved int `json:"orphanIdsRemoved"` } // 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"` } // /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) == "" { 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 } resp := cleanupResp{} // 1) Kleine Downloads löschen (wenn mb > 0) if mb > 0 { threshold := int64(mb) * 1024 * 1024 cleanupSmallFiles(doneAbs, threshold, &resp) } // 2) Orphans entfernen (immer sinnvoll, unabhängig von mb) cleanupOrphanAssets(doneAbs, &resp) // ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen, // damit die Zahlen in der JSON-Response landen. gcStats := triggerGeneratedGarbageCollectorSync() resp.OrphanIDsScanned += gcStats.Checked resp.OrphanIDsRemoved += gcStats.Removed resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) writeJSON(w, http.StatusOK, resp) } 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" } // scan: doneAbs + 1-level subdirs, "keep" wird übersprungen scanDir := 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 } resp.ScannedFiles++ if fi.Size() < threshold { base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) id := stripHotPrefix(base) if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) { resp.DeletedFiles++ resp.DeletedBytes += fi.Size() // generated + legacy cleanup (best effort) if strings.TrimSpace(id) != "" { removeGeneratedForID(id) _ = os.RemoveAll(filepath.Join(doneAbs, "preview", id)) _ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id)) } purgeDurationCacheForPath(p) } else { resp.ErrorCount++ } } } continue } // root-level file 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 } resp.ScannedFiles++ if fi.Size() < threshold { base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)) id := stripHotPrefix(base) if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) { resp.DeletedFiles++ resp.DeletedBytes += fi.Size() if strings.TrimSpace(id) != "" { removeGeneratedForID(id) _ = os.RemoveAll(filepath.Join(doneAbs, "preview", id)) _ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id)) } purgeDurationCacheForPath(full) } else { resp.ErrorCount++ } } } } scanDir(doneAbs, true) } // Orphans = Preview/Thumbs/Generated IDs, für die keine Video-Datei im doneAbs existiert. func cleanupOrphanAssets(doneAbs string, resp *cleanupResp) { // 1) Existierende Video-IDs einsammeln existingIDs := collectExistingVideoIDs(doneAbs) // 2) Orphan-IDs aus preview/thumbs ermitteln previewDir := filepath.Join(doneAbs, "preview") thumbsDir := filepath.Join(doneAbs, "thumbs") ids := make(map[string]struct{}) addDirChildrenAsIDs := func(dir string) { ents, err := os.ReadDir(dir) if err != nil { return } for _, e := range ents { if !e.IsDir() { continue } id := strings.TrimSpace(e.Name()) if id == "" { continue } ids[id] = struct{}{} } } addDirChildrenAsIDs(previewDir) addDirChildrenAsIDs(thumbsDir) resp.OrphanIDsScanned = len(ids) // 3) Alles löschen, was nicht mehr existiert for id := range ids { if _, ok := existingIDs[id]; ok { continue } // remove generated artifacts (best effort) removeGeneratedForID(id) // remove legacy preview/thumbs _ = os.RemoveAll(filepath.Join(previewDir, id)) _ = os.RemoveAll(filepath.Join(thumbsDir, id)) resp.OrphanIDsRemoved++ } } func collectExistingVideoIDs(doneAbs string) map[string]struct{} { out := make(map[string]struct{}) 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" } addFile := func(p string) { name := filepath.Base(p) if !isCandidate(name) { return } base := strings.TrimSuffix(name, filepath.Ext(name)) id := stripHotPrefix(base) id = strings.TrimSpace(id) if id != "" { out[id] = struct{}{} } } // root + 1-level subdirs (skip keep) ents, err := os.ReadDir(doneAbs) if err != nil { return out } for _, e := range ents { full := filepath.Join(doneAbs, e.Name()) if e.IsDir() { if e.Name() == "keep" { continue } sub, err := os.ReadDir(full) if err != nil { continue } for _, se := range sub { if se.IsDir() { continue } addFile(filepath.Join(full, se.Name())) } continue } addFile(full) } return out }