From de4bfe8cefd59fe35aeac19ff2a44291668996d6 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sat, 2 May 2026 10:37:02 +0200 Subject: [PATCH] bugfixes --- backend/disk_guard.go | 281 ++++++++++-------- backend/recorder.go | 4 + frontend/src/components/ui/Icons.tsx | 37 +++ .../src/components/ui/RecorderSettings.tsx | 2 +- frontend/src/components/ui/TrainingTab.tsx | 13 +- 5 files changed, 203 insertions(+), 134 deletions(-) diff --git a/backend/disk_guard.go b/backend/disk_guard.go index 9e4677f..65412d9 100644 --- a/backend/disk_guard.go +++ b/backend/disk_guard.go @@ -23,7 +23,7 @@ import ( // ------------------------- const ( - diskGuardInterval = 5 * time.Second + diskGuardInterval = 1 * time.Second ) var diskEmergency int32 // 0=false, 1=true @@ -47,18 +47,7 @@ func diskStatusHandler(w http.ResponseWriter, r *http.Request) { 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 - } - } + free, dir, _ := minFreeBytesForDiskGuard(s) resp := diskStatusResp{ Emergency: atomic.LoadInt32(&diskEmergency) == 1, @@ -175,60 +164,61 @@ func inFlightBytesForJob(j *RecordJob) uint64 { if j == nil { return 0 } - // Prefer live-tracked bytes if available (accurate & cheap). + + // Dateisystem ist die harte Wahrheit. SizeBytes kann bei laufenden Downloads + // verzögert oder gar nicht aktualisiert sein. + statSize := sizeOfPathBestEffort(j.Output) + + liveSize := uint64(0) if j.SizeBytes > 0 { - return uint64(j.SizeBytes) + liveSize = uint64(j.SizeBytes) } - return sizeOfPathBestEffort(j.Output) + + if statSize > liveSize { + return statSize + } + return liveSize } const giB = uint64(1024 * 1024 * 1024) // computeDiskThresholds: -// Pause = max(lowDiskPauseBelowGB, ceil(relevantInFlightBytes / GiB)) -// Resume = Pause + 3 GB (Hysterese) // -// relevantInFlightBytes = Summe aller laufenden Downloads, -// deren aktuelle Dateigröße über AutoDeleteSmallDownloadsBelowMB liegt. +// Wir brauchen: +// 1. die konfigurierte Mindestreserve +// 2. zusätzlich ungefähr die Größe aller aktiven/queued TS-Dateien, +// weil TS -> MP4 temporär nochmal ungefähr dieselbe Größe benötigt. // -// Damit greift immer mindestens die konfigurierte Mindestschwelle, -// zusätzlich aber auch eine dynamische Schwelle basierend auf den -// "relevanten" Downloads, die nicht automatisch gelöscht würden. +// PauseNeed = configReserve + conversionReserve +// ResumeNeed = PauseNeed + 3 GiB Hysterese func computeDiskThresholds() (pauseGB int, resumeGB int, relevantInFlight uint64, pauseNeed uint64, resumeNeed uint64) { s := getSettings() - runningBytes := sumInFlightBytesAboveAutoDeleteThreshold() - queuedPostworkBytes := reservedQueuedPostworkBytesAboveAutoDeleteThreshold() - - // Gesamtrelevanz = laufende Schreiblast + reservierter Bedarf der anstehenden Nacharbeit - relevantInFlight = runningBytes + queuedPostworkBytes - configPauseGB := s.LowDiskPauseBelowGB if configPauseGB <= 0 { configPauseGB = 5 } - dynamicPauseGB := 0 - if relevantInFlight > 0 { - dynamicPauseGB = int((relevantInFlight + giB - 1) / giB) // ceil - } + runningBytes := sumActiveRecordingBytes() + postworkBytes := reservedPostworkBytes() - pauseGB = dynamicPauseGB - if pauseGB < configPauseGB { - pauseGB = configPauseGB - } + relevantInFlight = runningBytes + postworkBytes + + configReserve := uint64(configPauseGB) * giB + + pauseNeed = configReserve + relevantInFlight + resumeNeed = pauseNeed + 3*giB + + pauseGB = int((pauseNeed + giB - 1) / giB) + resumeGB = int((resumeNeed + giB - 1) / giB) 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 } @@ -254,16 +244,7 @@ func sumInFlightBytes() uint64 { return sum } -func sumInFlightBytesAboveAutoDeleteThreshold() uint64 { - s := getSettings() - - thresholdMB := s.AutoDeleteSmallDownloadsBelowMB - if thresholdMB < 0 { - thresholdMB = 0 - } - - thresholdBytes := uint64(thresholdMB) * 1024 * 1024 - +func sumActiveRecordingBytes() uint64 { var sum uint64 jobsMu.RLock() @@ -273,7 +254,9 @@ func sumInFlightBytesAboveAutoDeleteThreshold() uint64 { if j == nil { continue } - if j.Status != JobRunning { + + // Nur echte aktive Recorder. Postwork wird separat gezählt. + if !isActiveRecordingJob(j) { continue } @@ -282,44 +265,29 @@ func sumInFlightBytesAboveAutoDeleteThreshold() uint64 { continue } - // Nur Downloads berücksichtigen, die über der Auto-Delete-Grenze liegen. - // Kleine Dateien würden später ohnehin automatisch entfernt. - if size <= thresholdBytes { - continue - } - sum += size } return sum } -func shouldReserveQueuedPostworkJob(j *RecordJob) bool { +func shouldReservePostworkJob(j *RecordJob) bool { if j == nil { return false } - // Bereits laufende Jobs werden schon über sumInFlight... erfasst. - if j.Status == JobRunning { - return false - } - - // Nur Jobs mit PostWork-Bezug - if !isPostworkJob(j) { - return false - } - - // Terminale Jobs nicht berücksichtigen if isTerminalJobStatus(j.Status) { return false } - // Nur wenn wirklich noch ein PostWorkKey existiert + if !isPostworkJob(j) { + return false + } + if strings.TrimSpace(j.PostWorkKey) == "" { return false } - // queued / running aus der Queue-Info berücksichtigen if j.PostWork != nil { st := strings.ToLower(strings.TrimSpace(j.PostWork.State)) if st == "queued" || st == "running" { @@ -330,27 +298,18 @@ func shouldReserveQueuedPostworkJob(j *RecordJob) bool { } } - // Fallback-Heuristik state := getEffectivePostworkState(j) return state == "queued" || state == "running" } -func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 { - s := getSettings() - - thresholdMB := s.AutoDeleteSmallDownloadsBelowMB - if thresholdMB < 0 { - thresholdMB = 0 - } - thresholdBytes := uint64(thresholdMB) * 1024 * 1024 - +func reservedPostworkBytes() uint64 { var sum uint64 jobsMu.RLock() defer jobsMu.RUnlock() for _, j := range jobs { - if !shouldReserveQueuedPostworkJob(j) { + if !shouldReservePostworkJob(j) { continue } @@ -359,17 +318,72 @@ func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 { continue } - // Kleine Dateien ignorieren, wenn sie ohnehin auto-gelöscht würden - if size <= thresholdBytes { - continue - } - sum += size } return sum } +func resolveDiskGuardDirs(s RecorderSettings) []string { + rawDirs := []string{s.RecordDir, s.DoneDir} + + seen := map[string]bool{} + out := make([]string, 0, len(rawDirs)) + + for _, raw := range rawDirs { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + dir, _ := resolvePathRelativeToApp(raw) + dir = strings.TrimSpace(dir) + if dir == "" { + dir = raw + } + if dir == "" { + continue + } + + dir = filepath.Clean(dir) + key := strings.ToLower(dir) + + if seen[key] { + continue + } + seen[key] = true + out = append(out, dir) + } + + return out +} + +func minFreeBytesForDiskGuard(s RecorderSettings) (uint64, string, bool) { + dirs := resolveDiskGuardDirs(s) + if len(dirs) == 0 { + return 0, "", false + } + + var minFree uint64 + var minDir string + okAny := false + + for _, dir := range dirs { + u, err := godisk.Usage(dir) + if err != nil || u == nil { + continue + } + + if !okAny || u.Free < minFree { + minFree = u.Free + minDir = dir + okAny = true + } + } + + return minFree, minDir, okAny +} + // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser. // Bei wenig freiem Platz: // - diskEmergency aktivieren (Autostart blockieren) @@ -378,58 +392,31 @@ func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 { // Bei Erholung (Resume-Schwelle): // - diskEmergency automatisch wieder freigeben func startDiskSpaceGuard() { - t := time.NewTicker(diskGuardInterval) - defer t.Stop() - - for range t.C { + checkOnce := func() { 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 + free, dir, ok := minFreeBytesForDiskGuard(s) + if !ok { + return } - u, err := godisk.Usage(dir) - if err != nil || u == nil { - continue - } - free := u.Free - - // ✅ Schwellen: - // Pause = max(config lowDiskPauseBelowGB, ceil((2 * inFlight) / GiB)) - // Resume = Pause + 3 GB - // pauseNeed/resumeNeed sind die benötigten freien Bytes pauseGB, resumeGB, relevantInFlight, 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 isLowForPause := free < pauseNeed isHighEnoughForResume := free >= resumeNeed if !wasEmergency { - // Normalzustand: nur triggern, wenn unter Pause-Schwelle if !isLowForPause { - continue + return } atomic.StoreInt32(&diskEmergency, 1) broadcastAutostartPaused() fmt.Printf( - "🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, relevantBytes=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n", + "🛑 [disk] Low space: free=%s (%dB) (< need=%s, %dB, pause=%dGB resume=%dGB, reservedForTS2MP4=%s, %dB) -> stop downloads + block autostart (path=%s)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(pauseNeed)), pauseNeed, pauseGB, resumeGB, @@ -439,18 +426,18 @@ func startDiskSpaceGuard() { stopped := stopAllStoppableJobs() if stopped > 0 { - fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped) + fmt.Printf("🛑 [disk] Stop requested for %d download job(s)\n", stopped) } - continue + + return } - // ✅ 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, relevantBytes=%s, %dB) -> unblock autostart (path=%s)\n", + "✅ [disk] Space recovered: free=%s (%dB) (>= resume=%s, %dB, resume=%dGB, reservedForTS2MP4=%s, %dB) -> unblock autostart (path=%s)\n", formatBytesSI(u64ToI64(free)), free, formatBytesSI(u64ToI64(resumeNeed)), resumeNeed, resumeGB, @@ -459,4 +446,48 @@ func startDiskSpaceGuard() { ) } } + + // Sofort beim Start prüfen, nicht erst nach dem ersten Tick. + checkOnce() + + t := time.NewTicker(diskGuardInterval) + defer t.Stop() + + for range t.C { + checkOnce() + } +} + +func ensureDiskGuardAllowsStart() error { + if atomic.LoadInt32(&diskEmergency) == 1 { + return fmt.Errorf("Speicherplatz-Notbremse aktiv: neuer Download wird blockiert") + } + + s := getSettings() + + free, dir, ok := minFreeBytesForDiskGuard(s) + if !ok { + return nil + } + + pauseGB, resumeGB, relevantInFlight, pauseNeed, _ := computeDiskThresholds() + if free >= pauseNeed { + return nil + } + + atomic.StoreInt32(&diskEmergency, 1) + broadcastAutostartPaused() + + stopped := stopAllStoppableJobs() + + return fmt.Errorf( + "Speicherplatz-Notbremse aktiv: frei=%s, benötigt=%s, pause=%dGB, resume=%dGB, reserve=%s, gestoppt=%d, pfad=%s", + formatBytesSI(u64ToI64(free)), + formatBytesSI(u64ToI64(pauseNeed)), + pauseGB, + resumeGB, + formatBytesSI(u64ToI64(relevantInFlight)), + stopped, + dir, + ) } diff --git a/backend/recorder.go b/backend/recorder.go index ccfca57..c97e7c4 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -436,6 +436,10 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { return nil, fmt.Errorf("unsupported provider: %q", url) } + if err := ensureDiskGuardAllowsStart(); err != nil { + return nil, err + } + jobsMu.Lock() defer jobsMu.Unlock() diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 50d686b..90b991f 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -1182,6 +1182,43 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { const normalized = normalizeSegmentLabel(label) if (!normalized) return null + const labelKey = normalizeLabelKey(normalized) + + // Wichtig: + // Personen-Labels exakt vor jedem Fuzzy-Matching behandeln. + // Sonst kann "person" auch "person_male" / "person_female" matchen. + if (labelKey === 'person_male' || labelKey === 'male_person') { + return { + match: ['person_male', 'male_person'], + text: 'Männliche Person', + icon: MalePersonIcon, + } + } + + if (labelKey === 'person_female' || labelKey === 'female_person') { + return { + match: ['person_female', 'female_person'], + text: 'Weibliche Person', + icon: FemalePersonIcon, + } + } + + if (labelKey === 'person' || labelKey === 'person_unknown') { + return { + match: ['person_unknown', 'person'], + text: 'Person', + icon: UnknownContentIcon, + } + } + + // Erst exakte Matches prüfen. + for (const item of SEGMENT_LABEL_META) { + if (item.match.some((key) => labelKey === normalizeLabelKey(key))) { + return item + } + } + + // Danach erst weiche Matches prüfen. for (const item of SEGMENT_LABEL_META) { if (item.match.some((key) => labelMatches(normalized, key))) { return item diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 70fd42d..38c1f7e 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -636,7 +636,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } load() - const t = window.setInterval(load, 5000) // alle 5s aktualisieren + const t = window.setInterval(load, 1000) // jede Sekunde aktualisieren return () => { alive = false window.clearInterval(t) diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index c33c31b..12d3909 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -1235,17 +1235,14 @@ export default function TrainingTab() { }, [sample?.prediction.clothingPresent]) const peopleScores = useMemo(() => { - const entries = (sample?.prediction.boxes ?? []) - .filter((box) => box.label && labels.people.includes(box.label)) - .map((box) => [box.label, box.score]) - const bestScores = new Map() - for (const [label, score] of entries) { - const cleanLabel = String(label || '').trim() - const n = Number(score) + for (const box of sample?.prediction.boxes ?? []) { + const cleanLabel = String(box.label || '').trim() + const n = Number(box.score) - if (!cleanLabel || !Number.isFinite(n)) continue + if (!cleanLabel || !labels.people.includes(cleanLabel)) continue + if (!Number.isFinite(n)) continue const current = bestScores.get(cleanLabel) if (current === undefined || n > current) {