diff --git a/.gitignore b/.gitignore index 00b8528..e57570e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ backend/generated backend/nsfwapp.exe backend/web/dist backend/recorder_settings.json +backend/data/pending-autostart/__global__.json diff --git a/backend/analyze.go b/backend/analyze.go index 43c25b4..440c7ba 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -44,27 +44,15 @@ type analyzeVideoResp struct { Error string `json:"error,omitempty"` } -type aiRatingMeta struct { - Score float64 `json:"score"` - Stars int `json:"stars"` - Segments int `json:"segments"` - SegmentsPerMinute float64 `json:"segmentsPerMinute"` - FlaggedSeconds float64 `json:"flaggedSeconds"` - WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"` - CoverageRatio float64 `json:"coverageRatio"` - WeightedCoverageRatio float64 `json:"weightedCoverageRatio"` - LongestSegmentSeconds float64 `json:"longestSegmentSeconds"` - AvgConfidence float64 `json:"avgConfidence"` -} - type spriteFrameCandidate struct { Index int Time float64 } const ( - nsfwThresholdModerate = 0.35 - nsfwThresholdStrong = 0.60 + analyzeSegmentMergeGapSeconds = 8.0 + nsfwThresholdModerate = 0.35 + nsfwThresholdStrong = 0.60 ) var autoSelectedAILabels = map[string]struct{}{ @@ -633,8 +621,9 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. sameLabel := strings.EqualFold(cur.Label, n.Label) + gap := n.Start - cur.End - if sameLabel { + if sameLabel && gap <= analyzeSegmentMergeGapSeconds { if n.Start < cur.Start { cur.Start = n.Start } @@ -656,119 +645,116 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { return out } -func nsfwSegmentSeverityWeight(label string) float64 { - switch strings.ToLower(strings.TrimSpace(label)) { - case "female_genitalia_exposed": - return 1.00 - case "anus_exposed": - return 0.95 - case "female_breast_exposed": - return 0.85 - case "male_genitalia_exposed": - return 0.80 - case "buttocks_exposed": - return 0.65 - default: - return 0.50 - } -} +func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 { + const fallback = 3.0 -func starsFromNSFWScore(score float64) int { - switch { - case score < 5: - return 1 - case score < 15: - return 2 - case score < 35: - return 3 - case score < 60: - return 4 - default: - return 5 - } -} - -func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { - r := &aiRatingMeta{ - Score: 0, - Stars: 1, + if len(hits) < 2 { + return fallback } - if durationSec <= 0 || len(segments) == 0 { - return r - } + times := make([]float64, 0, len(hits)) - videoMinutes := math.Max(durationSec/60.0, 0.25) + for _, h := range hits { + t := h.Time - var totalFlagged float64 - var totalWeighted float64 - var longest float64 - var confSum float64 - var n int - - for _, s := range segments { - segDur := s.DurationSeconds - if segDur <= 0 { - segDur = s.EndSeconds - s.StartSeconds + if t < 0 { + if h.Start >= 0 { + t = h.Start + } else if h.End >= 0 { + t = h.End + } } - if segDur <= 0 { + + if t < 0 { continue } - sev := nsfwSegmentSeverityWeight(s.Label) - conf := clamp01(s.Score) + if duration > 0 { + t = math.Max(0, math.Min(t, duration)) + } - // Confidence beeinflusst, dominiert aber nicht. - weight := sev * (0.65 + 0.35*conf) + times = append(times, t) + } - totalFlagged += segDur - totalWeighted += segDur * weight - confSum += conf - n++ + if len(times) < 2 { + return fallback + } - if segDur > longest { - longest = segDur + sort.Float64s(times) + + gaps := make([]float64, 0, len(times)-1) + prev := times[0] + + for _, t := range times[1:] { + gap := t - prev + if gap > 0.05 { + gaps = append(gaps, gap) + prev = t } } - if n == 0 { - return r + if len(gaps) == 0 { + return fallback } - coverageRatio := clamp01(totalFlagged / durationSec) - weightedCoverageRatio := clamp01(totalWeighted / durationSec) - segmentsPerMinute := float64(n) / videoMinutes - avgConfidence := confSum / float64(n) + sort.Float64s(gaps) - // Normalisierung / Sättigung: - // - 20% gewichtete Abdeckung => voll - // - 4 Segmente pro Minute => voll - // - 20s längstes Segment => voll - coverageNorm := clamp01(weightedCoverageRatio / 0.20) - frequencyNorm := clamp01(segmentsPerMinute / 4.0) - longestNorm := clamp01(longest / 20.0) - confNorm := clamp01((avgConfidence - 0.35) / 0.45) + median := gaps[len(gaps)/2] + if len(gaps)%2 == 0 { + median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2 + } - raw := 0.55*coverageNorm + - 0.20*frequencyNorm + - 0.15*longestNorm + - 0.10*confNorm + // Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand, + // aber wir deckeln, damit Sparse-Hits nicht riesig werden. + span := median * 0.90 - score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle - stars := starsFromNSFWScore(score) + if span < 2 { + span = 2 + } + if span > 12 { + span = 12 + } - r.Score = score - r.Stars = stars - r.Segments = n - r.SegmentsPerMinute = math.Round(segmentsPerMinute*100) / 100 - r.FlaggedSeconds = math.Round(totalFlagged*100) / 100 - r.WeightedFlaggedSeconds = math.Round(totalWeighted*100) / 100 - r.CoverageRatio = math.Round(coverageRatio*10000) / 10000 - r.WeightedCoverageRatio = math.Round(weightedCoverageRatio*10000) / 10000 - r.LongestSegmentSeconds = math.Round(longest*100) / 100 - r.AvgConfidence = math.Round(avgConfidence*1000) / 1000 + return span +} - return r +func expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) { + if span <= 0 { + span = 3 + } + + if t < 0 { + t = 0 + } + + if duration > 0 { + t = math.Max(0, math.Min(t, duration)) + } + + half := span / 2 + + start := t - half + end := t + half + + if start < 0 { + start = 0 + } + if duration > 0 && end > duration { + end = duration + } + + if end <= start { + if duration > 0 { + end = math.Min(duration, start+math.Max(1, span)) + if end <= start { + start = math.Max(0, end-math.Max(1, span)) + } + } else { + end = start + math.Max(1, span) + } + } + + return start, end } func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { @@ -776,6 +762,8 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme return []aiSegmentMeta{} } + pointSpan := inferAnalyzePointSpanSeconds(hits, duration) + out := make([]aiSegmentMeta, 0, len(hits)) for _, hit := range hits { @@ -797,6 +785,7 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme end = hit.Time } } + if start > end { start, end = end, start } @@ -804,6 +793,18 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme start = math.Max(0, math.Min(start, duration)) end = math.Max(0, math.Min(end, duration)) + // Wichtig: + // Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End. + // Für Segmente und Rating brauchen sie aber eine kleine Dauer. + if end <= start { + marker := hit.Time + if marker < 0 { + marker = start + } + + start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) + } + if end <= start { continue } @@ -838,10 +839,12 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme for i := 1; i < len(out); i++ { n := out[i] - // Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen, - // unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt, - // wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten. - if strings.EqualFold(cur.Label, n.Label) { + gap := n.StartSeconds - cur.EndSeconds + if gap < 0 { + gap = 0 + } + + if strings.EqualFold(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { if n.StartSeconds < cur.StartSeconds { cur.StartSeconds = n.StartSeconds } diff --git a/backend/main.go b/backend/main.go index 4377994..ee26bbc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -389,8 +389,6 @@ var startedAtFromFilenameRe = regexp.MustCompile( ) func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { - // returns: (shouldDelete, sizeBytes, thresholdBytes) - s := getSettings() if !s.AutoDeleteSmallDownloads { return false, 0, 0 @@ -406,7 +404,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { return false, 0, 0 } - // relativ -> absolut versuchen (best effort) if !filepath.IsAbs(p) { if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" { p = abs @@ -420,9 +417,15 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { size := fi.Size() thr := int64(mb) * 1024 * 1024 + if size > 0 && size < thr { + if isFavoriteProtectedDownload(p, "") { + return false, size, thr + } + return true, size, thr } + return false, size, thr } diff --git a/backend/models_store.go b/backend/models_store.go index e47e249..558beb9 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -293,6 +293,46 @@ LIMIT 1; return &m, true } +func (s *ModelStore) IsFavoriteModel(host string, modelKey string) bool { + if s == nil { + return false + } + if err := s.ensureInit(); err != nil { + return false + } + + host = canonicalHost(host) + modelKey = strings.TrimSpace(modelKey) + if modelKey == "" { + return false + } + + var ok bool + + // host ist optional: + // - Wenn host gesetzt ist: exakter Host oder alte Legacy-Einträge ohne Host schützen. + // - Wenn host leer ist: alle Hosts nach diesem model_key prüfen. + err := s.db.QueryRow(` +SELECT EXISTS ( + SELECT 1 + FROM models + WHERE lower(trim(model_key)) = lower(trim($1)) + AND COALESCE(favorite, false) = true + AND ( + trim($2) = '' + OR lower(trim(COALESCE(host, ''))) = lower(trim($2)) + OR trim(COALESCE(host, '')) = '' + ) +); +`, modelKey, host).Scan(&ok) + + if err != nil { + return false + } + + return ok +} + func canonicalHost(host string) string { h := strings.ToLower(strings.TrimSpace(host)) h = strings.TrimPrefix(h, "www.") diff --git a/backend/rating.go b/backend/rating.go new file mode 100644 index 0000000..9e7100e --- /dev/null +++ b/backend/rating.go @@ -0,0 +1,186 @@ +// backend\rating.go + +package main + +import ( + "math" + "strings" +) + +type aiRatingMeta struct { + Score float64 `json:"score"` + Stars int `json:"stars"` + Segments int `json:"segments"` + SegmentsPerMinute float64 `json:"segmentsPerMinute"` + FlaggedSeconds float64 `json:"flaggedSeconds"` + WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"` + CoverageRatio float64 `json:"coverageRatio"` + WeightedCoverageRatio float64 `json:"weightedCoverageRatio"` + LongestSegmentSeconds float64 `json:"longestSegmentSeconds"` + AvgConfidence float64 `json:"avgConfidence"` +} + +func ratingClamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func ratingRound(v float64, places int) float64 { + p := math.Pow(10, float64(places)) + return math.Round(v*p) / p +} + +func ratingSmoothStep(v float64) float64 { + v = ratingClamp01(v) + return v * v * (3 - 2*v) +} + +func ratingSoftCap(value, knee float64) float64 { + if value <= 0 || knee <= 0 { + return 0 + } + return value / (value + knee) +} + +func ratingEffectiveDurationSeconds(seconds float64) float64 { + if seconds <= 0 { + return 0 + } + + // Lange Segmente zählen weiter, aber mit abnehmendem Zusatznutzen. + // Dadurch kippen falsche oder zu breite Merges nicht sofort auf 5 Sterne. + const kneeSeconds = 24.0 + return kneeSeconds * math.Log1p(seconds/kneeSeconds) +} + +func nsfwSegmentSeverityWeight(label string) float64 { + switch strings.ToLower(strings.TrimSpace(label)) { + case "female_genitalia_exposed": + return 1.00 + case "anus_exposed": + return 0.95 + case "female_breast_exposed": + return 0.85 + case "male_genitalia_exposed": + return 0.80 + case "buttocks_exposed": + return 0.65 + default: + return 0.50 + } +} + +func ratingConfidenceWeight(conf float64) float64 { + conf = ratingClamp01(conf) + + // Unterhalb ~0.30 soll Confidence kaum boosten. + // Oberhalb ~0.95 ist praktisch gesättigt. + n := ratingSmoothStep((conf - 0.30) / 0.65) + + return 0.60 + 0.40*n +} + +func starsFromNSFWScore(score float64) int { + switch { + case score < 15: + return 1 + case score < 35: + return 2 + case score < 58: + return 3 + case score < 78: + return 4 + default: + return 5 + } +} + +func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { + r := &aiRatingMeta{ + Score: 0, + Stars: 1, + } + + if durationSec <= 0 || len(segments) == 0 { + return r + } + + videoMinutes := math.Max(durationSec/60.0, 0.25) + + var totalFlagged float64 + var totalWeighted float64 + var totalEffectiveWeighted float64 + var longest float64 + var confSum float64 + var n int + + for _, s := range segments { + segDur := s.DurationSeconds + if segDur <= 0 { + segDur = s.EndSeconds - s.StartSeconds + } + if segDur <= 0 { + continue + } + + sev := nsfwSegmentSeverityWeight(s.Label) + conf := ratingClamp01(s.Score) + weight := sev * ratingConfidenceWeight(conf) + + totalFlagged += segDur + totalWeighted += segDur * weight + totalEffectiveWeighted += ratingEffectiveDurationSeconds(segDur) * weight + + confSum += conf + n++ + + if segDur > longest { + longest = segDur + } + } + + if n == 0 { + return r + } + + coverageRatio := ratingClamp01(totalFlagged / durationSec) + weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) + segmentsPerMinute := float64(n) / videoMinutes + avgConfidence := confSum / float64(n) + effectiveWeightedSecondsPerMinute := totalEffectiveWeighted / videoMinutes + + // Weichere Normalisierung statt harter Sättigung. + // Das reduziert 1/5-Ausreißer und verteilt mehr Fälle auf 2–4 Sterne. + densityNorm := ratingSoftCap(effectiveWeightedSecondsPerMinute, 8.0) + coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22) + frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25) + longestNorm := ratingSoftCap(longest, 25.0) + confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60) + + raw := + 0.38*densityNorm + + 0.30*coverageNorm + + 0.12*frequencyNorm + + 0.10*longestNorm + + 0.10*confNorm + + score := ratingRound(ratingClamp01(raw)*100, 1) + + r.Score = score + r.Stars = starsFromNSFWScore(score) + r.Segments = n + r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) + r.FlaggedSeconds = ratingRound(totalFlagged, 2) + r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2) + r.CoverageRatio = ratingRound(coverageRatio, 4) + r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4) + r.LongestSegmentSeconds = ratingRound(longest, 2) + r.AvgConfidence = ratingRound(avgConfidence, 3) + + return r +} diff --git a/backend/recorder.go b/backend/recorder.go index 8184ddd..ccfca57 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -985,6 +985,48 @@ func ensureUsableRecordedOutput(job *RecordJob, target JobStatus, out string) bo return false } +func favoriteProtectionModelKeyForDownload(out string, sourceURL string) (host string, modelKey string) { + sourceURL = strings.TrimSpace(sourceURL) + + if sourceURL != "" { + switch detectProvider(sourceURL) { + case "chaturbate": + host = "chaturbate.com" + modelKey = strings.TrimSpace(extractUsername(sourceURL)) + case "mfc": + host = "myfreecams.com" + modelKey = strings.TrimSpace(extractMFCUsername(sourceURL)) + } + } + + if strings.TrimSpace(modelKey) == "" { + stem := strings.TrimSuffix(filepath.Base(strings.TrimSpace(out)), filepath.Ext(out)) + stem = stripHotPrefix(stem) + modelKey = sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem))) + } + + return strings.TrimSpace(host), strings.ToLower(strings.TrimSpace(modelKey)) +} + +func isFavoriteProtectedDownload(out string, sourceURL string) bool { + s := getSettings() + if !s.AutoDeleteSmallDownloadsKeepFavorites { + return false + } + + host, modelKey := favoriteProtectionModelKeyForDownload(out, sourceURL) + if modelKey == "" { + return false + } + + store := getModelStore() + if store == nil { + return false + } + + return store.IsFavoriteModel(host, modelKey) +} + func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool { fi, err := os.Stat(out) if err != nil || fi == nil || fi.IsDir() { @@ -1006,6 +1048,11 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool { return false } + if isFavoriteProtectedDownload(out, job.SourceURL) { + fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out)) + return false + } + base := filepath.Base(out) id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) diff --git a/backend/settings.go b/backend/settings.go index 2f9d089..20f4cc2 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -29,9 +29,10 @@ type RecorderSettings struct { UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. - AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` - AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` - LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` + AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` + AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` + AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` + LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` // still | hover | all @@ -63,11 +64,12 @@ var ( EnableConcurrentDownloadsLimit: false, MaxConcurrentDownloads: 20, - UseChaturbateAPI: false, - UseMyFreeCamsWatcher: false, - AutoDeleteSmallDownloads: false, - AutoDeleteSmallDownloadsBelowMB: 50, - LowDiskPauseBelowGB: 5, + UseChaturbateAPI: false, + UseMyFreeCamsWatcher: false, + AutoDeleteSmallDownloads: false, + AutoDeleteSmallDownloadsBelowMB: 50, + AutoDeleteSmallDownloadsKeepFavorites: true, + LowDiskPauseBelowGB: 5, BlurPreviews: false, TeaserPlayback: "hover", @@ -226,9 +228,10 @@ type RecorderSettingsPublic struct { UseChaturbateAPI bool `json:"useChaturbateApi"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` - AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` - AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` - LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` + AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` + AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` + AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` + LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` @@ -259,9 +262,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { UseChaturbateAPI: s.UseChaturbateAPI, UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, - AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, - AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, - LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, + AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, + AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, + AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites, + LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, BlurPreviews: s.BlurPreviews, TeaserPlayback: s.TeaserPlayback, @@ -417,6 +421,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB + next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB next.BlurPreviews = in.BlurPreviews diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index da5f405..94bc8d8 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -461,6 +461,34 @@ func dirSizeBytes(root string) int64 { return total } +func removeDirIfEmpty(dir string) (bool, error) { + dir = strings.TrimSpace(dir) + if dir == "" { + return false, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + if len(entries) > 0 { + return false, nil + } + + if err := os.Remove(dir); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + return true, nil +} + // Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response). func triggerGeneratedGarbageCollectorSync() generatedGCStats { // nur 1 GC gleichzeitig @@ -603,7 +631,9 @@ func runGeneratedGarbageCollector() generatedGCStats { collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live) // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. - // Sonst räumt der GC generated/meta/ weg, obwohl RecordStream dort noch schreibt. + // Zusätzlich separat merken, damit leere Ordner aktiver Jobs nicht gelöscht werden. + activeJobIDs := make(map[string]struct{}, 128) + jobsMu.RLock() for _, j := range jobs { if j == nil { @@ -616,6 +646,7 @@ func runGeneratedGarbageCollector() generatedGCStats { } live[id] = struct{}{} + activeJobIDs[id] = struct{}{} } jobsMu.RUnlock() @@ -637,21 +668,38 @@ func runGeneratedGarbageCollector() generatedGCStats { if !e.IsDir() { continue } + id := strings.TrimSpace(e.Name()) if id == "" || strings.HasPrefix(id, ".") { continue } checkedMeta++ + + orphanDir := filepath.Join(metaRoot, id) + + // Leere generated/meta/-Ordner immer entfernen, + // außer ein aktiver Job könnte gerade hinein schreiben. + if _, active := activeJobIDs[id]; !active { + if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty { + removedMeta++ + continue + } + } + if _, ok := live[id]; ok { continue } - orphanDir := filepath.Join(metaRoot, id) orphanBytes := dirSizeBytes(orphanDir) removeGeneratedForID(id) + // Falls removeGeneratedForID nur Dateien gelöscht hat, den nun leeren Ordner entfernen. + if removedEmpty, err := removeDirIfEmpty(orphanDir); err == nil && removedEmpty { + // ok + } + if _, err := os.Stat(orphanDir); os.IsNotExist(err) { removedMeta++ removedBytes += orphanBytes diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd5a114..33ce2b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3949,7 +3949,7 @@ export default function App() { blurPreviews={Boolean(recSettings.blurPreviews)} teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserAudio={Boolean(recSettings.teaserAudio)} - pauseTeasers={Boolean(detailsModelKey) || splitModalOpen} + pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)} assetNonce={assetNonce} sortMode={doneSort} onSortModeChange={(m) => { diff --git a/frontend/src/components/ui/FinishedDownloadRating.tsx b/frontend/src/components/ui/FinishedDownloadRating.tsx deleted file mode 100644 index 7512844..0000000 --- a/frontend/src/components/ui/FinishedDownloadRating.tsx +++ /dev/null @@ -1,279 +0,0 @@ -// frontend\src\components\ui\FinishedDownloadRating.tsx - -'use client' - -import HoverPopover from './HoverPopover' -import type { RecordJobMeta } from '../../types' - -type FinishedDownloadRatingProps = { - metaRaw: unknown - variant?: 'overlay' | 'badge' - reserveSpace?: boolean -} - -function parseMetaObject(metaRaw: unknown): RecordJobMeta | null { - if (!metaRaw) return null - - if (typeof metaRaw === 'string') { - try { - return JSON.parse(metaRaw) as RecordJobMeta - } catch { - return null - } - } - - return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null -} - -function readRatingNode(metaRaw: unknown): Record | null { - const meta = parseMetaObject(metaRaw) as any - - const rating = - meta?.analysis?.ai?.rating ?? - meta?.ai?.rating ?? - null - - if (!rating || typeof rating !== 'object' || Array.isArray(rating)) { - return null - } - - return rating as Record -} - -function readNSFWStars(metaRaw: unknown): number | undefined { - const rating = readRatingNode(metaRaw) - - const raw = rating?.stars - const n = Number(raw) - if (!Number.isFinite(n)) return undefined - - return Math.max(1, Math.min(5, Math.floor(n))) -} - -function humanizeKey(key: string): string { - return key - .replace(/\./g, ' ') - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[_-]+/g, ' ') - .replace(/\b\w/g, (m) => m.toUpperCase()) -} - -function formatScalar(value: unknown): string | null { - if (value == null) return null - - if (typeof value === 'string') { - const s = value.trim() - return s || null - } - - if (typeof value === 'number') { - if (!Number.isFinite(value)) return null - if (Number.isInteger(value)) return String(value) - return value.toFixed(4).replace(/\.?0+$/, '') - } - - if (typeof value === 'boolean') { - return value ? 'Ja' : 'Nein' - } - - if (Array.isArray(value)) { - const parts = value.map(formatScalar).filter(Boolean) as string[] - return parts.length > 0 ? parts.join(', ') : null - } - - return null -} - -function readRatingEntries(metaRaw: unknown): Array<{ key: string; label: string; value: string }> { - const rating = readRatingNode(metaRaw) - if (!rating) return [] - - const out: Array<{ key: string; label: string; value: string }> = [] - - for (const [key, raw] of Object.entries(rating)) { - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - for (const [subKey, subRaw] of Object.entries(raw as Record)) { - if (subRaw && typeof subRaw === 'object') continue - - const formatted = formatScalar(subRaw) - if (!formatted) continue - - out.push({ - key: `${key}.${subKey}`, - label: humanizeKey(`${key} ${subKey}`), - value: formatted, - }) - } - continue - } - - const formatted = formatScalar(raw) - if (!formatted) continue - - out.push({ - key, - label: humanizeKey(key), - value: formatted, - }) - } - - out.sort((a, b) => { - if (a.key === 'stars') return -1 - if (b.key === 'stars') return 1 - return a.label.localeCompare(b.label, 'de') - }) - - return out -} - -function nsfwRatingTone(stars: number) { - switch (stars) { - case 1: - return { star: 'text-yellow-400 opacity-40' } - case 2: - return { star: 'text-yellow-400 opacity-55' } - case 3: - return { star: 'text-yellow-400 opacity-70' } - case 4: - return { star: 'text-yellow-400 opacity-85' } - default: - return { star: 'text-yellow-400 opacity-100' } - } -} - -function RatingStar({ - stars, - className, -}: { - stars: number - className: string -}) { - const tone = nsfwRatingTone(stars) - - return ( - - - - - {stars} - - - ) -} - -function RatingWithPopover({ - stars, - metaRaw, - className, -}: { - stars: number - metaRaw: unknown - className: string -}) { - const entries = readRatingEntries(metaRaw) - - const starNode = ( - - - - ) - - if (entries.length === 0) { - return starNode - } - - return ( - - open && ( -
-
-
- AI Rating -
-
- Bewertungswerte aus der Analyse -
-
- -
-
- {entries.map((entry) => ( -
- - {entry.label} - - - {entry.value} - -
- ))} -
-
-
- ) - } - > - {starNode} -
- ) -} - -export default function FinishedDownloadRating({ - metaRaw, - variant = 'overlay', - reserveSpace = false, -}: FinishedDownloadRatingProps) { - const stars = readNSFWStars(metaRaw) - - if (typeof stars !== 'number') { - if (reserveSpace && variant === 'badge') { - return