438 lines
9.7 KiB
Go
438 lines
9.7 KiB
Go
// 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/<id> 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/<id> 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
|
||
}
|