nsfwapp/backend/tasks_cleanup.go
2026-03-14 18:00:28 +01:00

327 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\tasks_cleanup.go
package main
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
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"`
// ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen)
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
}
// 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) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
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
}
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.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
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)
}
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)
}
purgeDurationCacheForPath(full)
} else {
resp.ErrorCount++
}
}
}
}
scanDir(doneAbs, true)
}
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/<id> 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
return stats
}