// backend\disk_guard.go package main import ( "context" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "time" godisk "github.com/shirou/gopsutil/v3/disk" ) // ------------------------- // Low disk space guard // - pausiert Autostart // - stoppt laufende Downloads // ------------------------- const ( diskGuardInterval = 5 * time.Second ) var diskEmergency int32 // 0=false, 1=true type diskStatusResp struct { Emergency bool `json:"emergency"` PauseGB int `json:"pauseGB"` ResumeGB int `json:"resumeGB"` FreeBytes uint64 `json:"freeBytes"` FreeBytesHuman string `json:"freeBytesHuman"` RecordPath string `json:"recordPath"` } func diskStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) return } s := getSettings() pauseGB, resumeGB, _, _, _ := computeDiskThresholds() recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) dir := strings.TrimSpace(recordDirAbs) if dir == "" { dir = strings.TrimSpace(s.RecordDir) } free := uint64(0) if dir != "" { if u, err := godisk.Usage(dir); err == nil && u != nil { free = u.Free } } resp := diskStatusResp{ Emergency: atomic.LoadInt32(&diskEmergency) == 1, PauseGB: pauseGB, ResumeGB: resumeGB, FreeBytes: free, FreeBytesHuman: formatBytesSI(int64(free)), RecordPath: dir, } w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, resp) } // stopJobsInternal markiert Jobs als "stopping" und cancelt sie (inkl. Preview-FFmpeg Kill). // Nutzt 2 notify-Pushes, damit die UI Phase/Progress sofort sieht. func stopJobsInternal(list []*RecordJob) { if len(list) == 0 { return } type payload struct { cmd *exec.Cmd cancel context.CancelFunc } pl := make([]payload, 0, len(list)) jobsMu.Lock() for _, job := range list { if job == nil { continue } job.Phase = "stopping" job.Progress = 10 pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) job.previewCmd = nil } jobsMu.Unlock() notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress) for _, p := range pl { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() } if p.cancel != nil { p.cancel() } } notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen } func stopAllStoppableJobs() int { stoppable := make([]*RecordJob, 0, 16) jobsMu.Lock() for _, j := range jobs { if j == nil { continue } if j.Status != JobRunning { continue } phase := strings.ToLower(strings.TrimSpace(j.Phase)) // ✅ Im Disk-Notfall ALLES stoppen, was noch schreibt. // Wir skippen nur Jobs, die sowieso schon im "stopping" sind. if phase == "stopping" { continue } stoppable = append(stoppable, j) } jobsMu.Unlock() stopJobsInternal(stoppable) return len(stoppable) } func sizeOfPathBestEffort(p string) uint64 { p = strings.TrimSpace(p) if p == "" { return 0 } // relativ -> absolut versuchen if !filepath.IsAbs(p) { if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" { p = abs } } fi, err := os.Stat(p) if err != nil || fi.IsDir() || fi.Size() <= 0 { return 0 } return uint64(fi.Size()) } func inFlightBytesForJob(j *RecordJob) uint64 { if j == nil { return 0 } // Prefer live-tracked bytes if available (accurate & cheap). if j.SizeBytes > 0 { return uint64(j.SizeBytes) } return sizeOfPathBestEffort(j.Output) } func minRelevantInFlightBytes() uint64 { s := getSettings() // Nur wenn Auto-Delete kleine Downloads aktiv ist und eine sinnvolle Schwelle gesetzt ist if !s.AutoDeleteSmallDownloads { return 0 } mb := s.AutoDeleteSmallDownloadsBelowMB if mb <= 0 { return 0 } // MB -> Bytes (MiB passend zum restlichen Code mit GiB) return uint64(mb) * 1024 * 1024 } const giB = uint64(1024 * 1024 * 1024) // computeDiskThresholds: // Pause = ceil( (2 * inFlightBytes) / GiB ) // Resume = Pause + 3 GB (Hysterese) // Wenn inFlight==0 => Pause/Resume = 0 func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseNeed uint64, resumeNeed uint64) { inFlight = sumInFlightBytes() if inFlight == 0 { return 0, 0, 0, 0, 0 } need := inFlight * 2 pauseGB = int((need + giB - 1) / giB) // ceil // Safety cap (nur zur Sicherheit, falls irgendwas eskaliert) if pauseGB > 10_000 { pauseGB = 10_000 } resumeGB = pauseGB + 3 if resumeGB > 10_000 { resumeGB = 10_000 } pauseNeed = uint64(pauseGB) * giB resumeNeed = uint64(resumeGB) * giB return } // ✅ Summe der "wachsenden" Daten (running + remuxing etc.) // Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve. func sumInFlightBytes() uint64 { var sum uint64 minKeepBytes := minRelevantInFlightBytes() jobsMu.Lock() defer jobsMu.Unlock() for _, j := range jobs { if j == nil { continue } if j.Status != JobRunning { continue } b := inFlightBytesForJob(j) // ✅ Nur "relevante" Dateien berücksichtigen: // Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs, // deren aktuelle Dateigröße bereits über der Schwelle liegt. // // Hinweis: Ein Job kann später noch über die Schwelle wachsen. // Diese Logik ist bewusst "weniger konservativ", so wie gewünscht. if minKeepBytes > 0 && b > 0 && b < minKeepBytes { continue } sum += b } return sum } // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser. // Bei wenig freiem Platz: // - diskEmergency aktivieren (Autostart blockieren) // - laufende Jobs stoppen // // Bei Erholung (Resume-Schwelle): // - diskEmergency automatisch wieder freigeben func startDiskSpaceGuard() { t := time.NewTicker(diskGuardInterval) defer t.Stop() for range t.C { s := getSettings() // Pfad bestimmen, auf dem wir freien Speicher prüfen recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) dir := strings.TrimSpace(recordDirAbs) if dir == "" { dir = strings.TrimSpace(s.RecordDir) } if dir == "" { continue } u, err := godisk.Usage(dir) if err != nil || u == nil { continue } free := u.Free // ✅ Dynamische Schwellen: // Pause = ceil((2 * inFlight) / GiB) // Resume = Pause + 3 GB // pauseNeed/resumeNeed sind die benötigten freien Bytes pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() // ✅ diskEmergency NICHT sticky behalten. // Stattdessen dynamisch mit Hysterese setzen/löschen: // // - triggern bei free < pauseNeed // - freigeben erst bei free >= resumeNeed // // So kann die Notbremse später erneut greifen. wasEmergency := atomic.LoadInt32(&diskEmergency) == 1 // Wenn aktuell nichts läuft, brauchen wir keine Reservierung. // Dann diskEmergency freigeben (falls gesetzt), damit Autostart wieder möglich ist. // (User-Pause bleibt davon unberührt.) if inFlight == 0 { if wasEmergency { atomic.StoreInt32(&diskEmergency, 0) broadcastAutostartPaused() fmt.Printf("✅ [disk] Emergency cleared (no in-flight jobs). free=%s (%dB) path=%s\n", formatBytesSI(u64ToI64(free)), free, dir, ) } continue } isLowForPause := free < pauseNeed isHighEnoughForResume := free >= resumeNeed if !wasEmergency { // Normalzustand: nur triggern, wenn unter Pause-Schwelle if !isLowForPause { continue } atomic.StoreInt32(&diskEmergency, 1) broadcastAutostartPaused() fmt.Printf( "🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(pauseNeed)), pauseNeed, pauseGB, resumeGB, formatBytesSI(u64ToI64(inFlight)), inFlight, dir, ) stopped := stopAllStoppableJobs() if stopped > 0 { fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped) } continue } // ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist if isHighEnoughForResume { atomic.StoreInt32(&diskEmergency, 0) broadcastAutostartPaused() fmt.Printf( "✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(resumeNeed)), resumeNeed, resumeGB, formatBytesSI(u64ToI64(inFlight)), inFlight, dir, ) } } }