// backend\tasks_cleanup.go package main import ( "context" "encoding/json" "errors" "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" ) type cleanupCandidate struct { path string name string size int64 } type recordCleanupFile struct { path string name string lowName string size int64 modTime time.Time } type recordCleanupDeleteCandidate struct { file recordCleanupFile reason string } type cleanupResp struct { // Small / broken downloads cleanup ScannedFiles int `json:"scannedFiles"` DeletedFiles int `json:"deletedFiles"` DeletedBrokenFiles int `json:"deletedBrokenFiles"` DeletedLowRatedFiles int `json:"deletedLowRatedFiles"` SkippedFiles int `json:"skippedFiles"` DeletedBytes int64 `json:"deletedBytes"` DeletedBytesHuman string `json:"deletedBytesHuman"` DeletedRecordOrphans int `json:"deletedRecordOrphans"` DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"` DeletedMuxingTemps int `json:"deletedMuxingTemps"` DeletedAudioOrphans int `json:"deletedAudioOrphans"` DeletedTSOrphans int `json:"deletedTSOrphans"` 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"` } type cleanupTaskConfig struct { DoneAbs string RecordAbs string BelowMB int DeleteLowRated bool MaxStars int QueuedText string RunningText string } type cleanupTaskJob struct { State CleanupTaskState Cancel context.CancelFunc } var cleanupJobsMu sync.Mutex var cleanupJobs = map[string]*cleanupTaskJob{} const startupCleanupInitialDelay = 8 * time.Second func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState { st := CleanupTaskState{ ID: jobID, Queued: true, Running: false, Cancellable: cancel != nil, QueuedAt: time.Now(), StartedAt: time.Time{}, FinishedAt: nil, Text: strings.TrimSpace(text), Error: "", CurrentFile: "", Done: 0, Total: 0, } cleanupJobsMu.Lock() cleanupJobs[jobID] = &cleanupTaskJob{ State: st, Cancel: cancel, } cleanupJobsMu.Unlock() publishTaskState() return st } func snapshotCleanupJobs() []CleanupTaskState { cleanupJobsMu.Lock() defer cleanupJobsMu.Unlock() now := time.Now() out := make([]CleanupTaskState, 0, len(cleanupJobs)) for id, job := range cleanupJobs { if job == nil { delete(cleanupJobs, id) continue } st := job.State if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second { delete(cleanupJobs, id) continue } out = append(out, st) } return out } func updateCleanupJobState(jobID string, fn func(st *CleanupTaskState)) (CleanupTaskState, bool) { cleanupJobsMu.Lock() job := cleanupJobs[jobID] if job == nil { cleanupJobsMu.Unlock() return CleanupTaskState{}, false } fn(&job.State) st := job.State cleanupJobsMu.Unlock() publishTaskState() return st, true } func removeCleanupJob(jobID string) { cleanupJobsMu.Lock() delete(cleanupJobs, jobID) cleanupJobsMu.Unlock() publishTaskState() } func finishCleanupJob(jobID string, text string, err error) { now := time.Now() updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = false st.FinishedAt = &now st.CurrentFile = "" if err == nil { st.Error = "" st.Text = strings.TrimSpace(text) if st.Total > 0 { st.Done = st.Total } return } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { st.Error = "Abgebrochen" st.Text = "Abgebrochen." } else { st.Error = strings.TrimSpace(err.Error()) st.Text = "" } }) time.AfterFunc(4*time.Second, func() { removeCleanupJob(jobID) }) } func cleanupTaskConfigFromSettings(req *cleanupReq, queuedText string, runningText string) (cleanupTaskConfig, error) { s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { return cleanupTaskConfig{}, fmt.Errorf("doneDir auflösung fehlgeschlagen") } recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil || strings.TrimSpace(recordAbs) == "" { recordAbs = strings.TrimSpace(s.RecordDir) } mb := int(s.AutoDeleteSmallDownloadsBelowMB) if req != nil && req.BelowMB != nil { mb = *req.BelowMB } if mb < 0 { mb = 0 } return cleanupTaskConfig{ DoneAbs: doneAbs, RecordAbs: recordAbs, BelowMB: mb, DeleteLowRated: s.AutoDeleteLowRatedDownloads, MaxStars: clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars), QueuedText: strings.TrimSpace(queuedText), RunningText: strings.TrimSpace(runningText), }, nil } func startCleanupTask(parentCtx context.Context, jobPrefix string, cfg cleanupTaskConfig) CleanupTaskState { if parentCtx == nil { parentCtx = context.Background() } jobPrefix = strings.TrimSpace(jobPrefix) if jobPrefix == "" { jobPrefix = "cleanup" } queuedText := strings.TrimSpace(cfg.QueuedText) if queuedText == "" { queuedText = "Wartet…" } runningText := strings.TrimSpace(cfg.RunningText) if runningText == "" { runningText = "Räume auf…" } jobID := newTaskID(jobPrefix) ctx, cancel := context.WithCancel(parentCtx) st := createCleanupJob(jobID, queuedText, cancel) go func(jobID string, ctx context.Context, cancel context.CancelFunc, cfg cleanupTaskConfig, runningText string) { defer cancel() if err := acquireExclusiveTask(ctx); err != nil { finishCleanupJob(jobID, "", err) return } defer releaseExclusiveTask() updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true st.StartedAt = time.Now() st.Text = runningText }) resp := cleanupResp{} if cfg.BelowMB > 0 || cfg.DeleteLowRated { threshold := int64(cfg.BelowMB) * 1024 * 1024 if err := cleanupSmallFiles(ctx, jobID, cfg.DoneAbs, threshold, cfg.DeleteLowRated, cfg.MaxStars, &resp); err != nil { finishCleanupJob(jobID, "", err) return } } // Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen. updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true if st.StartedAt.IsZero() { st.StartedAt = time.Now() } st.Done = 0 st.Total = 0 st.CurrentFile = "" st.Text = "Räume Record-Reste auf…" st.Error = "" st.FinishedAt = nil }) if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, cfg.RecordAbs, &resp); err != nil { finishCleanupJob(jobID, "", err) return } select { case <-ctx.Done(): finishCleanupJob(jobID, "", ctx.Err()) return default: } updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true st.Done = 0 st.Total = 0 st.CurrentFile = "" st.Text = "Räume Generated-Assets auf…" st.Error = "" st.FinishedAt = nil }) gcStats := triggerGeneratedGarbageCollectorSync() resp.GeneratedOrphansChecked = gcStats.Checked resp.GeneratedOrphansRemoved = gcStats.Removed resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) resp.DeletedBytes += gcStats.RemovedBytes resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { notifyDoneChanged() } finishCleanupJob( jobID, cleanupProgressText(resp), nil, ) }(jobID, ctx, cancel, cfg, runningText) return st } func startCleanupTaskOnceOnStartup(parentCtx context.Context) { if parentCtx == nil { parentCtx = context.Background() } appGo("startup-cleanup", func() { timer := time.NewTimer(startupCleanupInitialDelay) defer timer.Stop() select { case <-parentCtx.Done(): return case <-timer.C: } cfg, err := cleanupTaskConfigFromSettings(nil, "Wartet…", "Räume auf…") if err != nil { appLogln("⚠️ [startup-cleanup] cleanup config failed:", err) return } startCleanupTask(parentCtx, "cleanup-startup", cfg) }) } // /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) { switch r.Method { case http.MethodPost: var req cleanupReq if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } cfg, err := cleanupTaskConfigFromSettings(&req, "Wartet…", "Räume auf…") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } st := startCleanupTask(context.Background(), "cleanup", cfg) writeJSON(w, http.StatusOK, st) return case http.MethodDelete: id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) return } cleanupJobsMu.Lock() job := cleanupJobs[id] cleanupJobsMu.Unlock() if job != nil && job.Cancel != nil { job.Cancel() } w.WriteHeader(http.StatusNoContent) return default: http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed) return } } func cleanupProgressText(resp cleanupResp) string { parts := []string{ fmt.Sprintf("gelöscht: %d/%d", resp.DeletedFiles, resp.ScannedFiles), } if resp.DeletedBrokenFiles > 0 { parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles)) } if resp.DeletedLowRatedFiles > 0 { parts = append(parts, fmt.Sprintf("Rating: %d", resp.DeletedLowRatedFiles)) } if resp.DeletedRecordOrphans > 0 { parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans)) } if resp.DeletedTSOrphans > 0 { parts = append(parts, fmt.Sprintf("TS ohne Audio: %d", resp.DeletedTSOrphans)) } if resp.DeletedAudioOrphans > 0 { parts = append(parts, fmt.Sprintf("Audio ohne TS: %d", resp.DeletedAudioOrphans)) } if resp.DeletedMuxingTemps > 0 { parts = append(parts, fmt.Sprintf("Muxing-Temp: %d", resp.DeletedMuxingTemps)) } if resp.GeneratedOrphansRemoved > 0 { parts = append(parts, fmt.Sprintf("Generated: %d", resp.GeneratedOrphansRemoved)) } if resp.ErrorCount > 0 { parts = append(parts, fmt.Sprintf("Fehler: %d", resp.ErrorCount)) } parts = append(parts, fmt.Sprintf("freigegeben: %s", formatBytesSI(resp.DeletedBytes))) return strings.Join(parts, " · ") } func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) { path = strings.TrimSpace(path) if path == "" { return false, "" } ext := strings.ToLower(filepath.Ext(path)) if ext != ".mp4" && ext != ".ts" { return false, "" } fi, err := os.Stat(path) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return true, "file missing/empty" } // Erst ffprobe/duration: wenn keine Dauer lesbar ist, ist die Datei meistens kaputt. dctx, cancel := context.WithTimeout(ctx, 8*time.Second) dur, derr := durationSecondsCached(dctx, path) dctxErr := dctx.Err() cancel() if errors.Is(dctxErr, context.DeadlineExceeded) || errors.Is(dctxErr, context.Canceled) { // Timeout beim Prüfen nicht als "kaputt" werten, um keine guten Dateien wegen Last zu löschen. return false, "duration check timeout" } if derr != nil || dur <= 0 { if derr != nil { return true, "duration failed: " + derr.Error() } return true, "duration <= 0" } // Danach konservative Decode-Prüfung am Ende. // Diese Funktion nutzt du bereits nach dem Remux in recorder.go. vctx, vcancel := context.WithTimeout(ctx, 20*time.Second) verr := validateVideoDecodesNearEnd(vctx, path) vctxErr := vctx.Err() vcancel() if errors.Is(vctxErr, context.DeadlineExceeded) || errors.Is(vctxErr, context.Canceled) { return false, "decode check timeout" } if verr != nil { return true, "decode failed: " + verr.Error() } return false, "" } const ( cleanupRecordOrphanMinAge = 60 * time.Second cleanupRecordStableDelay = 1200 * time.Millisecond ) func cleanupRecordPathKey(path string) string { path = filepath.Clean(strings.TrimSpace(path)) if path == "" { return "" } return strings.ToLower(path) } func cleanupRecordPathIsActive(path string, activePaths map[string]struct{}) bool { key := cleanupRecordPathKey(path) if key == "" || activePaths == nil { return false } _, ok := activePaths[key] return ok } func snapshotCleanupRecordActivePaths() map[string]struct{} { active := make(map[string]struct{}, 32) add := func(path string) { key := cleanupRecordPathKey(path) if key != "" { active[key] = struct{}{} } } jobsMu.RLock() defer jobsMu.RUnlock() for _, j := range jobs { if j == nil { continue } out := filepath.Clean(strings.TrimSpace(j.Output)) add(out) for _, sidecar := range postworkAudioSidecarCandidatesForOutput(out) { add(sidecar) } } return active } func cleanupAudioSidecarNamesForTS(tsName string) []string { tsName = strings.TrimSpace(filepath.Base(tsName)) if tsName == "" || strings.HasPrefix(tsName, ".") || !strings.EqualFold(filepath.Ext(tsName), ".ts") { return nil } base := strings.TrimSuffix(tsName, filepath.Ext(tsName)) if strings.TrimSpace(base) == "" { return nil } names := []string{ base + ".audio.m4s", "." + base + ".audio.m4s", } if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" && !strings.EqualFold(canonical, base) { names = append(names, canonical+".audio.m4s", "."+canonical+".audio.m4s", ) } return names } func cleanupHasAudioSidecarForTS(tsName string, filesByLowName map[string]recordCleanupFile) bool { for _, sidecarName := range cleanupAudioSidecarNamesForTS(tsName) { f, ok := filesByLowName[strings.ToLower(sidecarName)] if ok && f.size > 0 { return true } } return false } func cleanupWaitForStableRecordCandidates( ctx context.Context, candidates []recordCleanupDeleteCandidate, activePaths map[string]struct{}, resp *cleanupResp, ) ([]recordCleanupDeleteCandidate, error) { if len(candidates) == 0 { return nil, nil } now := time.Now() pendingStable := make([]recordCleanupDeleteCandidate, 0, len(candidates)) ready := make([]recordCleanupDeleteCandidate, 0, len(candidates)) for _, candidate := range candidates { select { case <-ctx.Done(): return nil, ctx.Err() default: } if now.Sub(candidate.file.modTime) < cleanupRecordOrphanMinAge { resp.SkippedFiles++ continue } if cleanupRecordPathIsActive(candidate.file.path, activePaths) { resp.SkippedFiles++ continue } if candidate.file.size <= 0 { ready = append(ready, candidate) continue } pendingStable = append(pendingStable, candidate) } if len(pendingStable) == 0 { return ready, nil } timer := time.NewTimer(cleanupRecordStableDelay) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } for _, candidate := range pendingStable { select { case <-ctx.Done(): return nil, ctx.Err() default: } fi, err := os.Stat(candidate.file.path) if err != nil || fi == nil || fi.IsDir() { resp.SkippedFiles++ continue } if fi.Size() != candidate.file.size { resp.SkippedFiles++ continue } candidate.file.size = fi.Size() candidate.file.modTime = fi.ModTime() ready = append(ready, candidate) } return ready, nil } func cleanupDeleteRecordOrphanCandidate(candidate recordCleanupDeleteCandidate, resp *cleanupResp) { if resp == nil { return } file := candidate.file if err := removeWithRetry(file.path); err != nil && !os.IsNotExist(err) { resp.ErrorCount++ appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", file.name, candidate.reason, err) return } resp.DeletedFiles++ resp.DeletedRecordOrphans++ resp.DeletedBytes += file.size resp.DeletedRecordOrphanBytes += file.size if strings.HasSuffix(file.lowName, ".muxing.ts") { resp.DeletedMuxingTemps++ } else if strings.HasSuffix(file.lowName, ".audio.m4s") { resp.DeletedAudioOrphans++ } else if !strings.HasPrefix(file.name, ".") && strings.EqualFold(filepath.Ext(file.name), ".ts") { resp.DeletedTSOrphans++ } purgeDurationCacheForPath(file.path) base := strings.TrimSuffix(filepath.Base(file.path), filepath.Ext(file.path)) base = strings.TrimPrefix(base, ".") base = strings.TrimSuffix(base, ".audio") base = strings.TrimSuffix(base, ".muxing") if strings.TrimSpace(base) != "" { removeGeneratedForID(stripHotPrefix(base)) } appLogln("🧹 cleanup deleted record orphan:", file.name, "reason:", candidate.reason, "size=", file.size) } func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error { recordAbs = strings.TrimSpace(recordAbs) if recordAbs == "" || resp == nil { return nil } entries, err := os.ReadDir(recordAbs) if err != nil { return nil } files := make([]recordCleanupFile, 0, len(entries)) filesByLowName := make(map[string]recordCleanupFile, len(entries)) for _, e := range entries { if e == nil || e.IsDir() { continue } name := strings.TrimSpace(e.Name()) if name == "" { continue } info, err := e.Info() if err != nil || info == nil || info.IsDir() { continue } file := recordCleanupFile{ path: filepath.Join(recordAbs, name), name: name, lowName: strings.ToLower(name), size: info.Size(), modTime: info.ModTime(), } files = append(files, file) filesByLowName[file.lowName] = file } audioSidecarMatchedByLowName := make(map[string]bool, len(files)) for _, file := range files { if strings.HasPrefix(file.name, ".") || !strings.EqualFold(filepath.Ext(file.name), ".ts") || file.size <= 0 { continue } for _, sidecarName := range cleanupAudioSidecarNamesForTS(file.name) { lowSidecarName := strings.ToLower(sidecarName) if _, ok := filesByLowName[lowSidecarName]; ok { audioSidecarMatchedByLowName[lowSidecarName] = true } } } updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true if st.StartedAt.IsZero() { st.StartedAt = time.Now() } st.Done = 0 st.Total = len(files) st.CurrentFile = "" st.Text = "Suche Record-Reste…" st.Error = "" st.FinishedAt = nil }) activePaths := snapshotCleanupRecordActivePaths() candidates := make([]recordCleanupDeleteCandidate, 0, 64) lastProgressAt := time.Time{} updateProgress := func(done, total int, currentFile, text string, force bool) { now := time.Now() if !force && now.Sub(lastProgressAt) < 250*time.Millisecond && done%100 != 0 { return } lastProgressAt = now updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Done = done st.Total = total st.CurrentFile = strings.TrimSpace(currentFile) if strings.TrimSpace(text) != "" { st.Text = text } }) } for i, file := range files { select { case <-ctx.Done(): return ctx.Err() default: } updateProgress(i+1, len(files), file.name, "Suche Record-Reste…", false) // Fall 0: // alte .muxing.ts sind temporäre Remux-/Muxing-Reste. if strings.HasSuffix(file.lowName, ".muxing.ts") { resp.ScannedFiles++ candidates = append(candidates, recordCleanupDeleteCandidate{ file: file, reason: "stale muxing temp", }) continue } // Fall 1: // foo.ts existiert, aber kein passendes foo.audio.m4s/.foo.audio.m4s. if !strings.HasPrefix(file.name, ".") && strings.EqualFold(filepath.Ext(file.name), ".ts") { resp.ScannedFiles++ if cleanupHasAudioSidecarForTS(file.name, filesByLowName) { continue } candidates = append(candidates, recordCleanupDeleteCandidate{ file: file, reason: "missing audio sidecar", }) continue } // Fall 2: // foo.audio.m4s/.foo.audio.m4s existiert, aber foo.ts fehlt. if strings.HasSuffix(file.lowName, ".audio.m4s") { resp.ScannedFiles++ if audioSidecarMatchedByLowName[file.lowName] { continue } candidates = append(candidates, recordCleanupDeleteCandidate{ file: file, reason: "missing ts file", }) continue } } updateProgress(len(files), len(files), "", "Prüfe stabile Record-Reste…", true) stableCandidates, err := cleanupWaitForStableRecordCandidates(ctx, candidates, activePaths, resp) if err != nil { return err } updateProgress(0, len(stableCandidates), "", "Lösche Record-Reste…", true) activePaths = snapshotCleanupRecordActivePaths() for i, candidate := range stableCandidates { select { case <-ctx.Done(): return ctx.Err() default: } updateProgress(i+1, len(stableCandidates), candidate.file.name, "Lösche Record-Reste…", false) if cleanupRecordPathIsActive(candidate.file.path, activePaths) { resp.SkippedFiles++ continue } cleanupDeleteRecordOrphanCandidate(candidate, resp) } updateProgress(len(stableCandidates), len(stableCandidates), "", cleanupProgressText(*resp), true) return nil } func clampAutoDeleteRatingStars(v int) int { if v < 1 { return 3 } if v > 5 { return 5 } return v } func ratingStarsForVideoPath(videoPath string) (int, bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return 0, false, nil } base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath)) assetID := stripHotPrefix(base) if strings.TrimSpace(assetID) == "" { return 0, false, nil } metaPath, err := generatedMetaFile(assetID) if err != nil { return 0, false, err } m, ok := readVideoMetaLoose(metaPath) if !ok || m == nil { return 0, false, nil } ai := pickAnalysisAIForGoal(m.Analysis, "highlights") if ai == nil || ai.Rating == nil { return 0, false, nil } stars := ai.Rating.Stars if stars <= 0 && ai.Rating.Score > 0 { stars = starsFromHighlightScore(ai.Rating.Score) } if stars < 1 || stars > 5 { return 0, false, nil } return stars, true, nil } func deleteDoneVideoAndGenerated(videoPath string) (int64, error) { videoPath = filepath.Clean(strings.TrimSpace(videoPath)) if videoPath == "" { return 0, nil } ext := strings.ToLower(filepath.Ext(videoPath)) if ext != ".mp4" && ext != ".ts" { return 0, nil } fi, err := os.Stat(videoPath) if err != nil { if os.IsNotExist(err) { return 0, nil } return 0, err } if fi == nil || fi.IsDir() { return 0, nil } name := filepath.Base(videoPath) base := strings.TrimSuffix(name, filepath.Ext(name)) assetID := stripHotPrefix(base) // Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei, // damit Windows-Dateihandles nicht blockieren. releaseFileForMutation(name) if err := removeWithRetry(videoPath); err != nil && !os.IsNotExist(err) { return 0, err } if strings.TrimSpace(assetID) != "" { removeGeneratedForID(assetID) } purgeDurationCacheForPath(videoPath) return fi.Size(), nil } func autoDeleteLowRatedDownloadAfterAnalysis(ctx context.Context, videoPath string, rating *aiRatingMeta) { select { case <-ctx.Done(): return default: } videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return } s := getSettings() if !s.AutoDeleteLowRatedDownloads { return } maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars) stars := 0 if rating != nil { stars = rating.Stars if stars <= 0 && rating.Score > 0 { stars = starsFromHighlightScore(rating.Score) } } // Wichtig für Background-/Regenerate-Analyse: // Dort wird rating teilweise als nil übergeben, obwohl es kurz vorher // in meta.json gespeichert wurde. Dann lesen wir die Sterne aus der Meta. if stars <= 0 { if metaStars, ok, err := ratingStarsForVideoPath(videoPath); err != nil { appLogln("⚠️ [rating-delete] rating lookup failed:", filepath.Base(videoPath), err) } else if ok { stars = metaStars } } if stars < 1 || stars > 5 { appLogf( "ℹ️ [rating-delete] übersprungen, kein gültiges Rating: %s stars=%d threshold<=%d", filepath.Base(videoPath), stars, maxStars, ) return } if stars > maxStars { appLogf( "ℹ️ [rating-delete] behalten: %s stars=%d threshold<=%d", filepath.Base(videoPath), stars, maxStars, ) return } deletedBytes, err := deleteDoneVideoAndGenerated(videoPath) if err != nil { appLogln("⚠️ [rating-delete] konnte Download nicht löschen:", filepath.Base(videoPath), err) return } appLogf( "🗑️ [rating-delete] gelöscht nach Analyse: %s stars=%d threshold<=%d bytes=%s", filepath.Base(videoPath), stars, maxStars, formatBytesSI(deletedBytes), ) notifyDoneChanged() } func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, deleteLowRated bool, maxStars int, resp *cleanupResp) error { 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) error { ents, err := os.ReadDir(dir) if err != nil { return nil } for _, e := range ents { select { case <-ctx.Done(): return ctx.Err() default: } 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(), }) } return nil } if err := collectCandidates(doneAbs, true); err != nil { return err } resp.ScannedFiles = len(candidates) updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true if st.StartedAt.IsZero() { st.StartedAt = time.Now() } st.Done = 0 st.Total = resp.ScannedFiles st.CurrentFile = "" st.Text = cleanupProgressText(*resp) st.Error = "" st.FinishedAt = nil }) for i, c := range candidates { select { case <-ctx.Done(): return ctx.Err() default: } deleteReason := "" if threshold > 0 && c.size < threshold { deleteReason = "small" } else if deleteLowRated { stars, ok, err := ratingStarsForVideoPath(c.path) if err != nil { appLogln("⚠️ cleanup rating check failed:", filepath.Base(c.path), err) } else if ok && stars <= maxStars { deleteReason = "rating" appLogf( "🧹 cleanup deleting low-rated video: %s stars=%d threshold<=%d", filepath.Base(c.path), stars, maxStars, ) } } if deleteReason == "" { broken, why := cleanupVideoLooksBroken(ctx, c.path) if broken { deleteReason = "broken" appLogln("🧹 cleanup deleting broken video:", filepath.Base(c.path), why) } } if deleteReason != "" { deletedBytes, derr := deleteDoneVideoAndGenerated(c.path) if derr == nil { resp.DeletedFiles++ if deletedBytes > 0 { resp.DeletedBytes += deletedBytes } else { resp.DeletedBytes += c.size } switch deleteReason { case "broken": resp.DeletedBrokenFiles++ case "rating": resp.DeletedLowRatedFiles++ } } else { resp.ErrorCount++ } } updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true if st.StartedAt.IsZero() { st.StartedAt = time.Now() } st.Done = i + 1 st.Total = resp.ScannedFiles st.CurrentFile = c.name st.Text = cleanupProgressText(*resp) st.Error = "" st.FinishedAt = nil }) } return nil } 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 } func removeDirIfEmpty(dir string) (bool, error) { dir = strings.TrimSpace(dir) if dir == "" { return false, nil } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } if len(entries) > 0 { return false, nil } if err := os.Remove(dir); err != nil { if os.IsNotExist(err) { return false, nil } return false, err } return true, nil } // 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) { appLogln("🧹 [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() }() } func collectLiveIDsFromTrash(trashDir string, live map[string]struct{}) { trashDir = strings.TrimSpace(trashDir) if trashDir == "" || live == nil { return } entries, err := os.ReadDir(trashDir) if err != nil { return } for _, e := range entries { if e == nil || e.IsDir() { continue } name := strings.TrimSpace(e.Name()) if name == "" { continue } // last.json ignorieren if strings.EqualFold(name, "last.json") { continue } // Format in .trash: // __ parts := strings.SplitN(name, "__", 2) if len(parts) != 2 { continue } originalFile := strings.TrimSpace(parts[1]) if originalFile == "" { continue } ext := strings.ToLower(filepath.Ext(originalFile)) if ext != ".mp4" && ext != ".ts" { continue } full := filepath.Join(trashDir, name) fi, err := os.Stat(full) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { continue } id := stripHotPrefix(strings.TrimSuffix(filepath.Base(originalFile), ext)) id = strings.TrimSpace(id) if id == "" { continue } if sid, err := sanitizeID(id); err == nil && sid != "" { live[sid] = struct{}{} } } } // 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 { appLogln("🧹 [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 zunächst 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 }) // 1a) Dateien in .trash ebenfalls als "live" behandeln, // damit ihre Generated-Dateien erst gelöscht werden, // wenn sie wirklich endgültig aus .trash verschwinden. collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live) // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. // Zusätzlich separat merken, damit leere Ordner aktiver Jobs nicht gelöscht werden. activeJobIDs := make(map[string]struct{}, 128) jobsMu.RLock() for _, j := range jobs { if j == nil { continue } id := strings.TrimSpace(assetIDForJob(j)) if id == "" { continue } live[id] = struct{}{} activeJobIDs[id] = struct{}{} } jobsMu.RUnlock() // 2) /generated/meta/ 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++ orphanDir := filepath.Join(metaRoot, id) // Leere generated/meta/-Ordner immer entfernen, // außer ein aktiver Job könnte gerade hinein schreiben. if _, active := activeJobIDs[id]; !active { if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty { removedMeta++ continue } } if _, ok := live[id]; ok { continue } orphanBytes := dirSizeBytes(orphanDir) removeGeneratedForID(id) // Falls removeGeneratedForID nur Dateien gelöscht hat, den nun leeren Ordner entfernen. if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty { // ok } 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 }