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 (
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,
)
}

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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<string, number>()
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) {