This commit is contained in:
Linrador 2026-05-02 10:37:02 +02:00
parent 2d5ef7741a
commit de4bfe8cef
5 changed files with 203 additions and 134 deletions

View File

@ -23,7 +23,7 @@ import (
// ------------------------- // -------------------------
const ( const (
diskGuardInterval = 5 * time.Second diskGuardInterval = 1 * time.Second
) )
var diskEmergency int32 // 0=false, 1=true var diskEmergency int32 // 0=false, 1=true
@ -47,18 +47,7 @@ func diskStatusHandler(w http.ResponseWriter, r *http.Request) {
pauseGB, resumeGB, _, _, _ := computeDiskThresholds() pauseGB, resumeGB, _, _, _ := computeDiskThresholds()
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) free, dir, _ := minFreeBytesForDiskGuard(s)
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{ resp := diskStatusResp{
Emergency: atomic.LoadInt32(&diskEmergency) == 1, Emergency: atomic.LoadInt32(&diskEmergency) == 1,
@ -175,60 +164,61 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
if j == nil { if j == nil {
return 0 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 { 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) const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds: // computeDiskThresholds:
// Pause = max(lowDiskPauseBelowGB, ceil(relevantInFlightBytes / GiB))
// Resume = Pause + 3 GB (Hysterese)
// //
// relevantInFlightBytes = Summe aller laufenden Downloads, // Wir brauchen:
// deren aktuelle Dateigröße über AutoDeleteSmallDownloadsBelowMB liegt. // 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, // PauseNeed = configReserve + conversionReserve
// zusätzlich aber auch eine dynamische Schwelle basierend auf den // ResumeNeed = PauseNeed + 3 GiB Hysterese
// "relevanten" Downloads, die nicht automatisch gelöscht würden.
func computeDiskThresholds() (pauseGB int, resumeGB int, relevantInFlight uint64, pauseNeed uint64, resumeNeed uint64) { func computeDiskThresholds() (pauseGB int, resumeGB int, relevantInFlight uint64, pauseNeed uint64, resumeNeed uint64) {
s := getSettings() s := getSettings()
runningBytes := sumInFlightBytesAboveAutoDeleteThreshold()
queuedPostworkBytes := reservedQueuedPostworkBytesAboveAutoDeleteThreshold()
// Gesamtrelevanz = laufende Schreiblast + reservierter Bedarf der anstehenden Nacharbeit
relevantInFlight = runningBytes + queuedPostworkBytes
configPauseGB := s.LowDiskPauseBelowGB configPauseGB := s.LowDiskPauseBelowGB
if configPauseGB <= 0 { if configPauseGB <= 0 {
configPauseGB = 5 configPauseGB = 5
} }
dynamicPauseGB := 0 runningBytes := sumActiveRecordingBytes()
if relevantInFlight > 0 { postworkBytes := reservedPostworkBytes()
dynamicPauseGB = int((relevantInFlight + giB - 1) / giB) // ceil
}
pauseGB = dynamicPauseGB relevantInFlight = runningBytes + postworkBytes
if pauseGB < configPauseGB {
pauseGB = configPauseGB 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 { if pauseGB > 10_000 {
pauseGB = 10_000 pauseGB = 10_000
} }
resumeGB = pauseGB + 3
if resumeGB > 10_000 { if resumeGB > 10_000 {
resumeGB = 10_000 resumeGB = 10_000
} }
pauseNeed = uint64(pauseGB) * giB
resumeNeed = uint64(resumeGB) * giB
return return
} }
@ -254,16 +244,7 @@ func sumInFlightBytes() uint64 {
return sum return sum
} }
func sumInFlightBytesAboveAutoDeleteThreshold() uint64 { func sumActiveRecordingBytes() uint64 {
s := getSettings()
thresholdMB := s.AutoDeleteSmallDownloadsBelowMB
if thresholdMB < 0 {
thresholdMB = 0
}
thresholdBytes := uint64(thresholdMB) * 1024 * 1024
var sum uint64 var sum uint64
jobsMu.RLock() jobsMu.RLock()
@ -273,7 +254,9 @@ func sumInFlightBytesAboveAutoDeleteThreshold() uint64 {
if j == nil { if j == nil {
continue continue
} }
if j.Status != JobRunning {
// Nur echte aktive Recorder. Postwork wird separat gezählt.
if !isActiveRecordingJob(j) {
continue continue
} }
@ -282,44 +265,29 @@ func sumInFlightBytesAboveAutoDeleteThreshold() uint64 {
continue 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 sum += size
} }
return sum return sum
} }
func shouldReserveQueuedPostworkJob(j *RecordJob) bool { func shouldReservePostworkJob(j *RecordJob) bool {
if j == nil { if j == nil {
return false 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) { if isTerminalJobStatus(j.Status) {
return false return false
} }
// Nur wenn wirklich noch ein PostWorkKey existiert if !isPostworkJob(j) {
return false
}
if strings.TrimSpace(j.PostWorkKey) == "" { if strings.TrimSpace(j.PostWorkKey) == "" {
return false return false
} }
// queued / running aus der Queue-Info berücksichtigen
if j.PostWork != nil { if j.PostWork != nil {
st := strings.ToLower(strings.TrimSpace(j.PostWork.State)) st := strings.ToLower(strings.TrimSpace(j.PostWork.State))
if st == "queued" || st == "running" { if st == "queued" || st == "running" {
@ -330,27 +298,18 @@ func shouldReserveQueuedPostworkJob(j *RecordJob) bool {
} }
} }
// Fallback-Heuristik
state := getEffectivePostworkState(j) state := getEffectivePostworkState(j)
return state == "queued" || state == "running" return state == "queued" || state == "running"
} }
func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 { func reservedPostworkBytes() uint64 {
s := getSettings()
thresholdMB := s.AutoDeleteSmallDownloadsBelowMB
if thresholdMB < 0 {
thresholdMB = 0
}
thresholdBytes := uint64(thresholdMB) * 1024 * 1024
var sum uint64 var sum uint64
jobsMu.RLock() jobsMu.RLock()
defer jobsMu.RUnlock() defer jobsMu.RUnlock()
for _, j := range jobs { for _, j := range jobs {
if !shouldReserveQueuedPostworkJob(j) { if !shouldReservePostworkJob(j) {
continue continue
} }
@ -359,17 +318,72 @@ func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 {
continue continue
} }
// Kleine Dateien ignorieren, wenn sie ohnehin auto-gelöscht würden
if size <= thresholdBytes {
continue
}
sum += size sum += size
} }
return sum 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. // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz: // Bei wenig freiem Platz:
// - diskEmergency aktivieren (Autostart blockieren) // - diskEmergency aktivieren (Autostart blockieren)
@ -378,58 +392,31 @@ func reservedQueuedPostworkBytesAboveAutoDeleteThreshold() uint64 {
// Bei Erholung (Resume-Schwelle): // Bei Erholung (Resume-Schwelle):
// - diskEmergency automatisch wieder freigeben // - diskEmergency automatisch wieder freigeben
func startDiskSpaceGuard() { func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval) checkOnce := func() {
defer t.Stop()
for range t.C {
s := getSettings() s := getSettings()
// Pfad bestimmen, auf dem wir freien Speicher prüfen free, dir, ok := minFreeBytesForDiskGuard(s)
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) if !ok {
dir := strings.TrimSpace(recordDirAbs) return
if dir == "" {
dir = strings.TrimSpace(s.RecordDir)
}
if dir == "" {
continue
} }
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() 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 wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
isLowForPause := free < pauseNeed isLowForPause := free < pauseNeed
isHighEnoughForResume := free >= resumeNeed isHighEnoughForResume := free >= resumeNeed
if !wasEmergency { if !wasEmergency {
// Normalzustand: nur triggern, wenn unter Pause-Schwelle
if !isLowForPause { if !isLowForPause {
continue return
} }
atomic.StoreInt32(&diskEmergency, 1) atomic.StoreInt32(&diskEmergency, 1)
broadcastAutostartPaused() broadcastAutostartPaused()
fmt.Printf( 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(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed, formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB, pauseGB, resumeGB,
@ -439,18 +426,18 @@ func startDiskSpaceGuard() {
stopped := stopAllStoppableJobs() stopped := stopAllStoppableJobs()
if stopped > 0 { 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 { if isHighEnoughForResume {
atomic.StoreInt32(&diskEmergency, 0) atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused() broadcastAutostartPaused()
fmt.Printf( 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(free)), free,
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed, formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
resumeGB, 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,
)
} }

View File

@ -436,6 +436,10 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
return nil, fmt.Errorf("unsupported provider: %q", url) return nil, fmt.Errorf("unsupported provider: %q", url)
} }
if err := ensureDiskGuardAllowsStart(); err != nil {
return nil, err
}
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock() defer jobsMu.Unlock()

View File

@ -1182,6 +1182,43 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
const normalized = normalizeSegmentLabel(label) const normalized = normalizeSegmentLabel(label)
if (!normalized) return null 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) { for (const item of SEGMENT_LABEL_META) {
if (item.match.some((key) => labelMatches(normalized, key))) { if (item.match.some((key) => labelMatches(normalized, key))) {
return item return item

View File

@ -636,7 +636,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
load() load()
const t = window.setInterval(load, 5000) // alle 5s aktualisieren const t = window.setInterval(load, 1000) // jede Sekunde aktualisieren
return () => { return () => {
alive = false alive = false
window.clearInterval(t) window.clearInterval(t)

View File

@ -1235,17 +1235,14 @@ export default function TrainingTab() {
}, [sample?.prediction.clothingPresent]) }, [sample?.prediction.clothingPresent])
const peopleScores = useMemo(() => { 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<string, number>() const bestScores = new Map<string, number>()
for (const [label, score] of entries) { for (const box of sample?.prediction.boxes ?? []) {
const cleanLabel = String(label || '').trim() const cleanLabel = String(box.label || '').trim()
const n = Number(score) 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) const current = bestScores.get(cleanLabel)
if (current === undefined || n > current) { if (current === undefined || n > current) {