improved rating and added small favorite download protection

This commit is contained in:
Linrador 2026-04-28 15:02:57 +02:00
parent 68013e7b42
commit 590b113190
17 changed files with 1818 additions and 473 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ backend/generated
backend/nsfwapp.exe backend/nsfwapp.exe
backend/web/dist backend/web/dist
backend/recorder_settings.json backend/recorder_settings.json
backend/data/pending-autostart/__global__.json

View File

@ -44,27 +44,15 @@ type analyzeVideoResp struct {
Error string `json:"error,omitempty"` 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 { type spriteFrameCandidate struct {
Index int Index int
Time float64 Time float64
} }
const ( const (
nsfwThresholdModerate = 0.35 analyzeSegmentMergeGapSeconds = 8.0
nsfwThresholdStrong = 0.60 nsfwThresholdModerate = 0.35
nsfwThresholdStrong = 0.60
) )
var autoSelectedAILabels = map[string]struct{}{ var autoSelectedAILabels = map[string]struct{}{
@ -633,8 +621,9 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := strings.EqualFold(cur.Label, n.Label) sameLabel := strings.EqualFold(cur.Label, n.Label)
gap := n.Start - cur.End
if sameLabel { if sameLabel && gap <= analyzeSegmentMergeGapSeconds {
if n.Start < cur.Start { if n.Start < cur.Start {
cur.Start = n.Start cur.Start = n.Start
} }
@ -656,119 +645,116 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
return out return out
} }
func nsfwSegmentSeverityWeight(label string) float64 { func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 {
switch strings.ToLower(strings.TrimSpace(label)) { const fallback = 3.0
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 starsFromNSFWScore(score float64) int { if len(hits) < 2 {
switch { return fallback
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 durationSec <= 0 || len(segments) == 0 { times := make([]float64, 0, len(hits))
return r
}
videoMinutes := math.Max(durationSec/60.0, 0.25) for _, h := range hits {
t := h.Time
var totalFlagged float64 if t < 0 {
var totalWeighted float64 if h.Start >= 0 {
var longest float64 t = h.Start
var confSum float64 } else if h.End >= 0 {
var n int t = h.End
}
for _, s := range segments {
segDur := s.DurationSeconds
if segDur <= 0 {
segDur = s.EndSeconds - s.StartSeconds
} }
if segDur <= 0 {
if t < 0 {
continue continue
} }
sev := nsfwSegmentSeverityWeight(s.Label) if duration > 0 {
conf := clamp01(s.Score) t = math.Max(0, math.Min(t, duration))
}
// Confidence beeinflusst, dominiert aber nicht. times = append(times, t)
weight := sev * (0.65 + 0.35*conf) }
totalFlagged += segDur if len(times) < 2 {
totalWeighted += segDur * weight return fallback
confSum += conf }
n++
if segDur > longest { sort.Float64s(times)
longest = segDur
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 { if len(gaps) == 0 {
return r return fallback
} }
coverageRatio := clamp01(totalFlagged / durationSec) sort.Float64s(gaps)
weightedCoverageRatio := clamp01(totalWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / float64(n)
// Normalisierung / Sättigung: median := gaps[len(gaps)/2]
// - 20% gewichtete Abdeckung => voll if len(gaps)%2 == 0 {
// - 4 Segmente pro Minute => voll median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2
// - 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)
raw := 0.55*coverageNorm + // Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand,
0.20*frequencyNorm + // aber wir deckeln, damit Sparse-Hits nicht riesig werden.
0.15*longestNorm + span := median * 0.90
0.10*confNorm
score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle if span < 2 {
stars := starsFromNSFWScore(score) span = 2
}
if span > 12 {
span = 12
}
r.Score = score return span
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 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 { func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
@ -776,6 +762,8 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
return []aiSegmentMeta{} return []aiSegmentMeta{}
} }
pointSpan := inferAnalyzePointSpanSeconds(hits, duration)
out := make([]aiSegmentMeta, 0, len(hits)) out := make([]aiSegmentMeta, 0, len(hits))
for _, hit := range hits { for _, hit := range hits {
@ -797,6 +785,7 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
end = hit.Time end = hit.Time
} }
} }
if start > end { if start > end {
start, end = end, start start, end = end, start
} }
@ -804,6 +793,18 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
start = math.Max(0, math.Min(start, duration)) start = math.Max(0, math.Min(start, duration))
end = math.Max(0, math.Min(end, 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 { if end <= start {
continue continue
} }
@ -838,10 +839,12 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
for i := 1; i < len(out); i++ { for i := 1; i < len(out); i++ {
n := out[i] n := out[i]
// Direkt aufeinanderfolgende Segmente mit gleichem Label immer mergen, gap := n.StartSeconds - cur.EndSeconds
// unabhängig von der Lücke. Sobald ein anderes Label dazwischen liegt, if gap < 0 {
// wird automatisch nicht gemergt, weil wir nur mit dem direkten Nachfolger arbeiten. gap = 0
if strings.EqualFold(cur.Label, n.Label) { }
if strings.EqualFold(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds {
if n.StartSeconds < cur.StartSeconds { if n.StartSeconds < cur.StartSeconds {
cur.StartSeconds = n.StartSeconds cur.StartSeconds = n.StartSeconds
} }

View File

@ -389,8 +389,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
) )
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
s := getSettings() s := getSettings()
if !s.AutoDeleteSmallDownloads { if !s.AutoDeleteSmallDownloads {
return false, 0, 0 return false, 0, 0
@ -406,7 +404,6 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
return false, 0, 0 return false, 0, 0
} }
// relativ -> absolut versuchen (best effort)
if !filepath.IsAbs(p) { if !filepath.IsAbs(p) {
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" { if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
p = abs p = abs
@ -420,9 +417,15 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
size := fi.Size() size := fi.Size()
thr := int64(mb) * 1024 * 1024 thr := int64(mb) * 1024 * 1024
if size > 0 && size < thr { if size > 0 && size < thr {
if isFavoriteProtectedDownload(p, "") {
return false, size, thr
}
return true, size, thr return true, size, thr
} }
return false, size, thr return false, size, thr
} }

View File

@ -293,6 +293,46 @@ LIMIT 1;
return &m, true 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 { func canonicalHost(host string) string {
h := strings.ToLower(strings.TrimSpace(host)) h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimPrefix(h, "www.") h = strings.TrimPrefix(h, "www.")

186
backend/rating.go Normal file
View File

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

View File

@ -985,6 +985,48 @@ func ensureUsableRecordedOutput(job *RecordJob, target JobStatus, out string) bo
return false 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 { func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
fi, err := os.Stat(out) fi, err := os.Stat(out)
if err != nil || fi == nil || fi.IsDir() { if err != nil || fi == nil || fi.IsDir() {
@ -1006,6 +1048,11 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
return false return false
} }
if isFavoriteProtectedDownload(out, job.SourceURL) {
fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out))
return false
}
base := filepath.Base(out) base := filepath.Base(out)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))

View File

@ -29,9 +29,10 @@ type RecorderSettings struct {
UseChaturbateAPI bool `json:"useChaturbateApi"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
// Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind. // Wenn aktiv, werden fertige Downloads automatisch gelöscht, wenn sie kleiner als der Grenzwert sind.
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"` BlurPreviews bool `json:"blurPreviews"`
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
@ -63,11 +64,12 @@ var (
EnableConcurrentDownloadsLimit: false, EnableConcurrentDownloadsLimit: false,
MaxConcurrentDownloads: 20, MaxConcurrentDownloads: 20,
UseChaturbateAPI: false, UseChaturbateAPI: false,
UseMyFreeCamsWatcher: false, UseMyFreeCamsWatcher: false,
AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloads: false,
AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsBelowMB: 50,
LowDiskPauseBelowGB: 5, AutoDeleteSmallDownloadsKeepFavorites: true,
LowDiskPauseBelowGB: 5,
BlurPreviews: false, BlurPreviews: false,
TeaserPlayback: "hover", TeaserPlayback: "hover",
@ -226,9 +228,10 @@ type RecorderSettingsPublic struct {
UseChaturbateAPI bool `json:"useChaturbateApi"` UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"` UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"` BlurPreviews bool `json:"blurPreviews"`
TeaserPlayback string `json:"teaserPlayback"` TeaserPlayback string `json:"teaserPlayback"`
@ -259,9 +262,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
UseChaturbateAPI: s.UseChaturbateAPI, UseChaturbateAPI: s.UseChaturbateAPI,
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher, UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites,
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
BlurPreviews: s.BlurPreviews, BlurPreviews: s.BlurPreviews,
TeaserPlayback: s.TeaserPlayback, TeaserPlayback: s.TeaserPlayback,
@ -417,6 +421,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
next.BlurPreviews = in.BlurPreviews next.BlurPreviews = in.BlurPreviews

View File

@ -461,6 +461,34 @@ func dirSizeBytes(root string) int64 {
return total 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). // Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
func triggerGeneratedGarbageCollectorSync() generatedGCStats { func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// nur 1 GC gleichzeitig // nur 1 GC gleichzeitig
@ -603,7 +631,9 @@ func runGeneratedGarbageCollector() generatedGCStats {
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live) collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
// Sonst räumt der GC generated/meta/<id> 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() jobsMu.RLock()
for _, j := range jobs { for _, j := range jobs {
if j == nil { if j == nil {
@ -616,6 +646,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
} }
live[id] = struct{}{} live[id] = struct{}{}
activeJobIDs[id] = struct{}{}
} }
jobsMu.RUnlock() jobsMu.RUnlock()
@ -637,21 +668,38 @@ func runGeneratedGarbageCollector() generatedGCStats {
if !e.IsDir() { if !e.IsDir() {
continue continue
} }
id := strings.TrimSpace(e.Name()) id := strings.TrimSpace(e.Name())
if id == "" || strings.HasPrefix(id, ".") { if id == "" || strings.HasPrefix(id, ".") {
continue continue
} }
checkedMeta++ checkedMeta++
orphanDir := filepath.Join(metaRoot, id)
// Leere generated/meta/<id>-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 { if _, ok := live[id]; ok {
continue continue
} }
orphanDir := filepath.Join(metaRoot, id)
orphanBytes := dirSizeBytes(orphanDir) orphanBytes := dirSizeBytes(orphanDir)
removeGeneratedForID(id) 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) { if _, err := os.Stat(orphanDir); os.IsNotExist(err) {
removedMeta++ removedMeta++
removedBytes += orphanBytes removedBytes += orphanBytes

View File

@ -3949,7 +3949,7 @@ export default function App() {
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)} teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen} pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
assetNonce={assetNonce} assetNonce={assetNonce}
sortMode={doneSort} sortMode={doneSort}
onSortModeChange={(m) => { onSortModeChange={(m) => {

View File

@ -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<string, unknown> | 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<string, unknown>
}
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<string, unknown>)) {
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 (
<span className={`relative inline-grid shrink-0 place-items-center leading-none ${className}`}>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className={`shrink-0 fill-current ${tone.star}`}
style={{
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.75)) drop-shadow(0 0 3px rgba(0,0,0,0.35))',
}}
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z"
stroke="rgba(0,0,0,0.75)"
strokeWidth="1.1"
paintOrder="stroke fill"
/>
</svg>
<span
className="absolute inset-0 grid place-items-center text-[10px] font-black leading-none text-white"
style={{
textShadow: '0 1px 2px rgba(0,0,0,0.95), 0 0 2px rgba(0,0,0,0.85)',
}}
>
{stars}
</span>
</span>
)
}
function RatingWithPopover({
stars,
metaRaw,
className,
}: {
stars: number
metaRaw: unknown
className: string
}) {
const entries = readRatingEntries(metaRaw)
const starNode = (
<span
className="inline-flex items-center justify-center"
aria-label={`Rating ${stars} von 5`}
>
<RatingStar stars={stars} className={className} />
</span>
)
if (entries.length === 0) {
return starNode
}
return (
<HoverPopover
content={(open) =>
open && (
<div className="w-[240px] rounded-lg">
<div className="border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
Bewertungswerte aus der Analyse
</div>
</div>
<div className="px-3 py-2">
<div className="space-y-1.5">
{entries.map((entry) => (
<div key={entry.key} className="flex items-start justify-between gap-3 text-[12px]">
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span className="shrink-0 font-medium text-gray-900 dark:text-white">
{entry.value}
</span>
</div>
))}
</div>
</div>
</div>
)
}
>
{starNode}
</HoverPopover>
)
}
export default function FinishedDownloadRating({
metaRaw,
variant = 'overlay',
reserveSpace = false,
}: FinishedDownloadRatingProps) {
const stars = readNSFWStars(metaRaw)
if (typeof stars !== 'number') {
if (reserveSpace && variant === 'badge') {
return <span aria-hidden="true" className="inline-block h-6 w-6 shrink-0 opacity-0" />
}
return null
}
if (variant === 'badge') {
return (
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center align-middle">
<RatingWithPopover
stars={stars}
metaRaw={metaRaw}
className="h-6 w-6"
/>
</span>
)
}
return (
<span className="inline-flex h-6 min-w-[24px] shrink-0 items-center justify-center">
<RatingWithPopover
stars={stars}
metaRaw={metaRaw}
className="h-6 w-6"
/>
</span>
)
}

View File

@ -59,7 +59,7 @@ import {
type FinishedSelectionStore, type FinishedSelectionStore,
} from './finishedSelectionStore' } from './finishedSelectionStore'
import { formatResolution, formatDuration, formatBytes } from './formatters' import { formatResolution, formatDuration, formatBytes } from './formatters'
import FinishedDownloadRating from './FinishedDownloadRating' import RatingOverlay from './RatingOverlay'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
@ -1614,8 +1614,7 @@ export default function FinishedDownloads({
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
const teaserPlaybackMode: TeaserPlaybackMode = const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
pauseTeasers ? 'still' : requestedTeaserPlaybackMode
const notify = useNotify() const notify = useNotify()
@ -1631,7 +1630,6 @@ export default function FinishedDownloads({
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode !canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
const previewMuted = !Boolean(teaserAudio)
const cardsMobileOffsetTopClass = 'mt-10' const cardsMobileOffsetTopClass = 'mt-10'
const cardsMobileOffsetBottomClass = 'mb-2' const cardsMobileOffsetBottomClass = 'mb-2'
@ -1805,22 +1803,39 @@ export default function FinishedDownloads({
return renderDownloadIntegrityBadge((job as any)?.meta) return renderDownloadIntegrityBadge((job as any)?.meta)
}, []) }, [])
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
onOpenPlayer(job, startAtSec)
}, [onOpenPlayer])
const renderRatingOverlay = useCallback((job: RecordJob) => { const renderRatingOverlay = useCallback((job: RecordJob) => {
return ( return (
<FinishedDownloadRating <RatingOverlay
metaRaw={(job as any)?.meta} metaRaw={(job as any)?.meta}
variant="overlay" variant="overlay"
onOpenAt={(seconds) => {
openPlayerAndPauseTeasers(job, seconds)
}}
/> />
) )
}, []) }, [openPlayerAndPauseTeasers])
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// memoized derived data // memoized derived data
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const forcePreviewMuted = const forcePreviewMuted =
mobileNeedsTapForAudio && teaserAudio Boolean(pauseTeasers) ||
(mobileNeedsTapForAudio && teaserAudio
? !mobileTeaserAudioUnlocked ? !mobileTeaserAudioUnlocked
: false : false)
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
const audibleTeaserKey =
previewMuted
? null
: canHover
? hoverTeaserKey
: inlinePlay?.key ?? teaserKey
const deferredSearchQuery = useDeferredValue(searchQuery) const deferredSearchQuery = useDeferredValue(searchQuery)
@ -1848,9 +1863,9 @@ export default function FinishedDownloads({
mode: effectiveTeaserPlaybackMode, mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey, activeKey: teaserKey,
hoverKey: hoverTeaserKey, hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio), audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
}), }),
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio] [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
) )
const modelTags = useMemo(() => { const modelTags = useMemo(() => {
@ -2347,16 +2362,26 @@ export default function FinishedDownloads({
const tryAutoplayInline = useCallback((domId: string) => { const tryAutoplayInline = useCallback((domId: string) => {
const host = document.getElementById(domId) const host = document.getElementById(domId)
const v = host?.querySelector('video') as HTMLVideoElement | null const v = host?.querySelector('video') as HTMLVideoElement | null
if (!v) return false if (!host || !v) return false
applyInlineVideoPolicy(v, { muted: previewMuted }) const teaserHost =
host.closest<HTMLElement>('[data-finished-hover-key]') ??
host.querySelector<HTMLElement>('[data-finished-hover-key]')
const key = teaserHost?.dataset.finishedHoverKey ?? null
const muted = !audibleTeaserKey || key !== audibleTeaserKey
applyInlineVideoPolicy(v, { muted })
v.defaultMuted = muted
v.muted = muted
const p = v.play?.() const p = v.play?.()
if (p && typeof (p as any).catch === 'function') { if (p && typeof (p as any).catch === 'function') {
(p as Promise<void>).catch(() => {}) ;(p as Promise<void>).catch(() => {})
} }
return true return true
}, [previewMuted]) }, [audibleTeaserKey])
const startInline = useCallback((key: string) => { const startInline = useCallback((key: string) => {
setInlinePlay((prev) => ( setInlinePlay((prev) => (
@ -2382,7 +2407,11 @@ export default function FinishedDownloads({
return return
} }
applyInlineVideoPolicy(v, { muted: previewMuted }) const muted = !audibleTeaserKey || key !== audibleTeaserKey
applyInlineVideoPolicy(v, { muted })
v.defaultMuted = muted
v.muted = muted
const applySeek = () => { const applySeek = () => {
try { try {
@ -2420,18 +2449,16 @@ export default function FinishedDownloads({
} }
requestAnimationFrame(() => trySeekAndPlay(8)) requestAnimationFrame(() => trySeekAndPlay(8))
}, [previewMuted]) }, [audibleTeaserKey])
const openPlayer = useCallback((job: RecordJob) => { const openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null) openPlayerAndPauseTeasers(job)
onOpenPlayer(job) }, [openPlayerAndPauseTeasers])
}, [onOpenPlayer])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => { const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0 const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
setInlinePlay(null) openPlayerAndPauseTeasers(job, s)
onOpenPlayer(job, s) }, [openPlayerAndPauseTeasers])
}, [onOpenPlayer])
const handleScrubberClickIndex = useCallback( const handleScrubberClickIndex = useCallback(
(job: RecordJob, segmentIndex: number, segmentCount: number) => { (job: RecordJob, segmentIndex: number, segmentCount: number) => {
@ -3548,12 +3575,17 @@ export default function FinishedDownloads({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!pauseTeasers) return for (const [key, host] of teaserHostsRef.current.entries()) {
const muted = !audibleTeaserKey || key !== audibleTeaserKey
const videos = host.querySelectorAll('video')
setTeaserKey(null) videos.forEach((video) => {
setHoverTeaserKey(null) applyInlineVideoPolicy(video, { muted })
setInlinePlay(null) video.defaultMuted = muted
}, [pauseTeasers]) video.muted = muted
})
}
}, [audibleTeaserKey, view, pageRows])
const refreshPostworkState = useCallback(async () => { const refreshPostworkState = useCallback(async () => {
try { try {
@ -3774,13 +3806,73 @@ export default function FinishedDownloads({
}, [refreshPostworkState]) }, [refreshPostworkState])
useEffect(() => { useEffect(() => {
if (!canHover) {
pointerPosRef.current = null
setHoverTeaserKey(null)
return
}
let raf: number | null = null
const syncHoverFromPointer = () => {
raf = null
const pos = pointerPosRef.current
if (!pos) {
setHoverTeaserKey((prev) => (prev === null ? prev : null))
return
}
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
const hoverHost = el?.closest<HTMLElement>('[data-finished-hover-key]') ?? null
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
}
const scheduleSync = () => {
if (raf != null) return
raf = requestAnimationFrame(syncHoverFromPointer)
}
const clearHover = () => {
pointerPosRef.current = null
setHoverTeaserKey((prev) => (prev === null ? prev : null))
}
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY } pointerPosRef.current = { x: e.clientX, y: e.clientY }
scheduleSync()
}
const onPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) {
clearHover()
}
}
const onVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
clearHover()
}
} }
window.addEventListener('pointermove', onPointerMove, { passive: true }) window.addEventListener('pointermove', onPointerMove, { passive: true })
return () => window.removeEventListener('pointermove', onPointerMove) window.addEventListener('pointerout', onPointerOut, { passive: true })
}, []) window.addEventListener('blur', clearHover)
window.addEventListener('scroll', scheduleSync, true)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (raf != null) cancelAnimationFrame(raf)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerout', onPointerOut)
window.removeEventListener('blur', clearHover)
window.removeEventListener('scroll', scheduleSync, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [canHover])
useEffect(() => { useEffect(() => {
try { try {
@ -4849,7 +4941,7 @@ export default function FinishedDownloads({
sizeBytesOf={sizeBytesOf} sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes} formatBytes={formatBytes}
lower={lowerTag} lower={lowerTag}
onOpenPlayer={onOpenPlayer} onOpenPlayer={openPlayerAndPauseTeasers}
openPlayer={openPlayer} openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt} onOpenPlayerAt={openPlayerAt}
handleScrubberClickIndex={handleScrubberClickIndex} handleScrubberClickIndex={handleScrubberClickIndex}
@ -4921,7 +5013,7 @@ export default function FinishedDownloads({
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onOpenPlayer={onOpenPlayer} onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex} handleScrubberClickIndex={handleScrubberClickIndex}
onSortModeChange={onSortModeChange} onSortModeChange={onSortModeChange}
page={page} page={page}
@ -4968,7 +5060,7 @@ export default function FinishedDownloads({
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer} onOpenPlayer={openPlayerAndPauseTeasers}
handleScrubberClickIndex={handleScrubberClickIndex} handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}

View File

@ -683,10 +683,7 @@ function FinishedDownloadsCardsView({
> >
{/* media */} {/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> <div className="absolute inset-0 overflow-hidden rounded-t-lg">
<PreviewRatingOverlay <PreviewRatingOverlay hidden={inlineActive}>
hidden={inlineActive}
raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
>
{renderRatingOverlay ? renderRatingOverlay(j) : null} {renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay> </PreviewRatingOverlay>

View File

@ -385,9 +385,7 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
<PreviewRatingOverlay <PreviewRatingOverlay>
raised={hasScrubber && hoveredThumbKey === k}
>
{renderRatingOverlay ? renderRatingOverlay(j) : null} {renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay> </PreviewRatingOverlay>

View File

@ -1,5 +1,7 @@
// frontend\src\components\ui\LoadingSpinner.tsx // frontend\src\components\ui\LoadingSpinner.tsx
// frontend/src/components/ui/LoadingSpinner.tsx
'use client' 'use client'
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
@ -44,36 +46,45 @@ export default function LoadingSpinner({
<span <span
className={cn( className={cn(
'inline-flex items-center gap-2', 'inline-flex items-center gap-2',
center && 'justify-center w-full', center && 'w-full justify-center',
)} )}
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={label ? undefined : srLabel}
> >
<svg <svg
width={px} width={px}
height={px} height={px}
viewBox="0 0 24 24" viewBox="0 0 24 24"
className={cn('animate-spin text-gray-500', className)} className={cn(
'block shrink-0 text-gray-500 motion-safe:animate-spin',
className,
)}
aria-hidden="true" aria-hidden="true"
> >
{/* Hintergrund-Ring (leicht transparent) */} {/* Hintergrund-Ring */}
<circle <circle
cx="12" cx="12"
cy="12" cy="12"
r="9" r="8.5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="3" strokeWidth="3"
opacity="0.25" opacity="0.22"
/> />
{/* “Arc” vorne */}
<path {/* Vorderes Segment mit runden Enden */}
<circle
cx="12"
cy="12"
r="8.5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
d="M21 12a9 9 0 0 0-9-9" strokeDasharray="22 54"
opacity="0.95" strokeDashoffset="0"
opacity="0.98"
/> />
</svg> </svg>
@ -84,4 +95,4 @@ export default function LoadingSpinner({
)} )}
</span> </span>
) )
} }

View File

@ -3,33 +3,21 @@
'use client' 'use client'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { PREVIEW_SCRUBBER_HEIGHT_PX } from './PreviewScrubber'
const PREVIEW_RATING_BASE_BOTTOM_PX = 7
type PreviewRatingOverlayProps = { type PreviewRatingOverlayProps = {
hidden?: boolean hidden?: boolean
children?: ReactNode children?: ReactNode
raised?: boolean
} }
export default function PreviewRatingOverlay({ export default function PreviewRatingOverlay({
hidden = false, hidden = false,
children, children,
raised = false,
}: PreviewRatingOverlayProps) { }: PreviewRatingOverlayProps) {
if (hidden || !children) return null if (hidden || !children) return null
const bottomPx = raised
? PREVIEW_RATING_BASE_BOTTOM_PX + PREVIEW_SCRUBBER_HEIGHT_PX
: PREVIEW_RATING_BASE_BOTTOM_PX
return ( return (
<div <div className="absolute right-1.5 top-1.5 z-40">
className="absolute left-1.5 z-40 transition-[bottom] duration-150" <div className="pointer-events-auto flex items-start justify-end">
style={{ bottom: `${bottomPx}px` }}
>
<div className="pointer-events-auto flex items-end">
{children} {children}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ type RecorderSettings = {
useMyFreeCamsWatcher?: boolean useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean
blurPreviews?: boolean blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all' teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
@ -60,6 +61,7 @@ const DEFAULTS: RecorderSettings = {
useMyFreeCamsWatcher: false, useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: true, autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true,
blurPreviews: false, blurPreviews: false,
teaserPlayback: 'hover', teaserPlayback: 'hover',
teaserAudio: false, teaserAudio: false,
@ -397,6 +399,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher, useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads, autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites:
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews, blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback, teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio, teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
@ -688,6 +692,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
0, 0,
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB))) Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
) )
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
const blurPreviews = !!value.blurPreviews const blurPreviews = !!value.blurPreviews
const teaserPlayback = const teaserPlayback =
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover' value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
@ -720,6 +725,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
useMyFreeCamsWatcher, useMyFreeCamsWatcher,
autoDeleteSmallDownloads, autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites,
blurPreviews, blurPreviews,
teaserPlayback, teaserPlayback,
teaserAudio, teaserAudio,
@ -1321,6 +1327,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</div> </div>
</div> </div>
<div
className={
'mt-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<LabeledSwitch
checked={!!value.autoDeleteSmallDownloadsKeepFavorites}
onChange={(checked) =>
setValue((v) => ({
...v,
autoDeleteSmallDownloadsKeepFavorites: checked,
}))
}
label="Favoriten nicht automatisch löschen"
description="Schützt Downloads von favorisierten Models vor dem automatischen Löschen, auch wenn die Datei kleiner als die Mindestgröße ist."
/>
</div>
</div> </div>
<LabeledSwitch <LabeledSwitch