311 lines
7.3 KiB
Go
311 lines
7.3 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"`
|
|
|
|
// ✅ 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) == "" {
|
|
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.GeneratedOrphansChecked = gcStats.Checked
|
|
resp.GeneratedOrphansRemoved = 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
|
|
}
|