327 lines
7.5 KiB
Go
327 lines
7.5 KiB
Go
// 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
|
||
}
|