380 lines
8.7 KiB
Go
380 lines
8.7 KiB
Go
// 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 {
|
|
jobID string
|
|
cmd *exec.Cmd
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
pl := make([]payload, 0, len(list))
|
|
changedIDs := make([]string, 0, len(list))
|
|
|
|
jobsMu.Lock()
|
|
for _, job := range list {
|
|
if job == nil {
|
|
continue
|
|
}
|
|
job.Phase = "stopping"
|
|
job.Progress = 10
|
|
|
|
pl = append(pl, payload{
|
|
jobID: job.ID,
|
|
cmd: job.previewCmd,
|
|
cancel: job.cancel,
|
|
})
|
|
|
|
job.previewCmd = nil
|
|
|
|
if strings.TrimSpace(job.ID) != "" {
|
|
changedIDs = append(changedIDs, job.ID)
|
|
}
|
|
}
|
|
jobsMu.Unlock()
|
|
|
|
// 1) UI sofort updaten
|
|
for _, id := range changedIDs {
|
|
notifyJobPatched(id)
|
|
}
|
|
|
|
for _, p := range pl {
|
|
if p.cmd != nil && p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
}
|
|
if p.cancel != nil {
|
|
p.cancel()
|
|
}
|
|
}
|
|
|
|
// 2) optional nochmal patchen, falls danach noch weitere Felder geändert wurden
|
|
for _, id := range changedIDs {
|
|
notifyJobPatched(id)
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
}
|