nsfwapp/backend/cleanup.go
2026-02-09 12:29:19 +01:00

307 lines
7.1 KiB
Go

// 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
}