improved rating and added small favorite download protection
This commit is contained in:
parent
68013e7b42
commit
590b113190
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ backend/generated
|
||||
backend/nsfwapp.exe
|
||||
backend/web/dist
|
||||
backend/recorder_settings.json
|
||||
backend/data/pending-autostart/__global__.json
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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.")
|
||||
|
||||
186
backend/rating.go
Normal file
186
backend/rating.go
Normal 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 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
|
||||
}
|
||||
@ -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)))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<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()
|
||||
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/<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 {
|
||||
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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -59,7 +59,7 @@ import {
|
||||
type FinishedSelectionStore,
|
||||
} from './finishedSelectionStore'
|
||||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||||
import FinishedDownloadRating from './FinishedDownloadRating'
|
||||
import RatingOverlay from './RatingOverlay'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
@ -1614,8 +1614,7 @@ export default function FinishedDownloads({
|
||||
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
|
||||
|
||||
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
||||
const teaserPlaybackMode: TeaserPlaybackMode =
|
||||
pauseTeasers ? 'still' : requestedTeaserPlaybackMode
|
||||
const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
|
||||
|
||||
const notify = useNotify()
|
||||
|
||||
@ -1631,7 +1630,6 @@ export default function FinishedDownloads({
|
||||
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
|
||||
|
||||
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
|
||||
const previewMuted = !Boolean(teaserAudio)
|
||||
|
||||
const cardsMobileOffsetTopClass = 'mt-10'
|
||||
const cardsMobileOffsetBottomClass = 'mb-2'
|
||||
@ -1805,22 +1803,39 @@ export default function FinishedDownloads({
|
||||
return renderDownloadIntegrityBadge((job as any)?.meta)
|
||||
}, [])
|
||||
|
||||
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
|
||||
onOpenPlayer(job, startAtSec)
|
||||
}, [onOpenPlayer])
|
||||
|
||||
const renderRatingOverlay = useCallback((job: RecordJob) => {
|
||||
return (
|
||||
<FinishedDownloadRating
|
||||
<RatingOverlay
|
||||
metaRaw={(job as any)?.meta}
|
||||
variant="overlay"
|
||||
onOpenAt={(seconds) => {
|
||||
openPlayerAndPauseTeasers(job, seconds)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
}, [openPlayerAndPauseTeasers])
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// memoized derived data
|
||||
// -----------------------------------------------------------------------------
|
||||
const forcePreviewMuted =
|
||||
mobileNeedsTapForAudio && teaserAudio
|
||||
Boolean(pauseTeasers) ||
|
||||
(mobileNeedsTapForAudio && teaserAudio
|
||||
? !mobileTeaserAudioUnlocked
|
||||
: false
|
||||
: false)
|
||||
|
||||
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
|
||||
|
||||
const audibleTeaserKey =
|
||||
previewMuted
|
||||
? null
|
||||
: canHover
|
||||
? hoverTeaserKey
|
||||
: inlinePlay?.key ?? teaserKey
|
||||
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||
|
||||
@ -1848,9 +1863,9 @@ export default function FinishedDownloads({
|
||||
mode: effectiveTeaserPlaybackMode,
|
||||
activeKey: teaserKey,
|
||||
hoverKey: hoverTeaserKey,
|
||||
audioEnabled: Boolean(teaserAudio),
|
||||
audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
|
||||
}),
|
||||
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
||||
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
|
||||
)
|
||||
|
||||
const modelTags = useMemo(() => {
|
||||
@ -2347,16 +2362,26 @@ export default function FinishedDownloads({
|
||||
const tryAutoplayInline = useCallback((domId: string) => {
|
||||
const host = document.getElementById(domId)
|
||||
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?.()
|
||||
if (p && typeof (p as any).catch === 'function') {
|
||||
(p as Promise<void>).catch(() => {})
|
||||
;(p as Promise<void>).catch(() => {})
|
||||
}
|
||||
|
||||
return true
|
||||
}, [previewMuted])
|
||||
}, [audibleTeaserKey])
|
||||
|
||||
const startInline = useCallback((key: string) => {
|
||||
setInlinePlay((prev) => (
|
||||
@ -2382,7 +2407,11 @@ export default function FinishedDownloads({
|
||||
return
|
||||
}
|
||||
|
||||
applyInlineVideoPolicy(v, { muted: previewMuted })
|
||||
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||||
|
||||
applyInlineVideoPolicy(v, { muted })
|
||||
v.defaultMuted = muted
|
||||
v.muted = muted
|
||||
|
||||
const applySeek = () => {
|
||||
try {
|
||||
@ -2420,18 +2449,16 @@ export default function FinishedDownloads({
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => trySeekAndPlay(8))
|
||||
}, [previewMuted])
|
||||
}, [audibleTeaserKey])
|
||||
|
||||
const openPlayer = useCallback((job: RecordJob) => {
|
||||
setInlinePlay(null)
|
||||
onOpenPlayer(job)
|
||||
}, [onOpenPlayer])
|
||||
openPlayerAndPauseTeasers(job)
|
||||
}, [openPlayerAndPauseTeasers])
|
||||
|
||||
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
|
||||
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
|
||||
setInlinePlay(null)
|
||||
onOpenPlayer(job, s)
|
||||
}, [onOpenPlayer])
|
||||
openPlayerAndPauseTeasers(job, s)
|
||||
}, [openPlayerAndPauseTeasers])
|
||||
|
||||
const handleScrubberClickIndex = useCallback(
|
||||
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
|
||||
@ -3548,12 +3575,17 @@ export default function FinishedDownloads({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pauseTeasers) return
|
||||
for (const [key, host] of teaserHostsRef.current.entries()) {
|
||||
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||||
const videos = host.querySelectorAll('video')
|
||||
|
||||
setTeaserKey(null)
|
||||
setHoverTeaserKey(null)
|
||||
setInlinePlay(null)
|
||||
}, [pauseTeasers])
|
||||
videos.forEach((video) => {
|
||||
applyInlineVideoPolicy(video, { muted })
|
||||
video.defaultMuted = muted
|
||||
video.muted = muted
|
||||
})
|
||||
}
|
||||
}, [audibleTeaserKey, view, pageRows])
|
||||
|
||||
const refreshPostworkState = useCallback(async () => {
|
||||
try {
|
||||
@ -3774,13 +3806,73 @@ export default function FinishedDownloads({
|
||||
}, [refreshPostworkState])
|
||||
|
||||
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) => {
|
||||
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 })
|
||||
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(() => {
|
||||
try {
|
||||
@ -4849,7 +4941,7 @@ export default function FinishedDownloads({
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lowerTag}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||
openPlayer={openPlayer}
|
||||
onOpenPlayerAt={openPlayerAt}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
@ -4921,7 +5013,7 @@ export default function FinishedDownloads({
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
onToggleTagFilter={toggleTagFilter}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
onSortModeChange={onSortModeChange}
|
||||
page={page}
|
||||
@ -4968,7 +5060,7 @@ export default function FinishedDownloads({
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
|
||||
@ -683,10 +683,7 @@ function FinishedDownloadsCardsView({
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
<PreviewRatingOverlay
|
||||
hidden={inlineActive}
|
||||
raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
|
||||
>
|
||||
<PreviewRatingOverlay hidden={inlineActive}>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
|
||||
@ -385,9 +385,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PreviewRatingOverlay
|
||||
raised={hasScrubber && hoveredThumbKey === k}
|
||||
>
|
||||
<PreviewRatingOverlay>
|
||||
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
||||
</PreviewRatingOverlay>
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// frontend\src\components\ui\LoadingSpinner.tsx
|
||||
|
||||
// frontend/src/components/ui/LoadingSpinner.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
@ -44,36 +46,45 @@ export default function LoadingSpinner({
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
center && 'justify-center w-full',
|
||||
center && 'w-full justify-center',
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={label ? undefined : srLabel}
|
||||
>
|
||||
<svg
|
||||
width={px}
|
||||
height={px}
|
||||
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"
|
||||
>
|
||||
{/* Hintergrund-Ring (leicht transparent) */}
|
||||
{/* Hintergrund-Ring */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
r="8.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
opacity="0.95"
|
||||
strokeDasharray="22 54"
|
||||
strokeDashoffset="0"
|
||||
opacity="0.98"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@ -84,4 +95,4 @@ export default function LoadingSpinner({
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -3,33 +3,21 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { PREVIEW_SCRUBBER_HEIGHT_PX } from './PreviewScrubber'
|
||||
|
||||
const PREVIEW_RATING_BASE_BOTTOM_PX = 7
|
||||
|
||||
type PreviewRatingOverlayProps = {
|
||||
hidden?: boolean
|
||||
children?: ReactNode
|
||||
raised?: boolean
|
||||
}
|
||||
|
||||
export default function PreviewRatingOverlay({
|
||||
hidden = false,
|
||||
children,
|
||||
raised = false,
|
||||
}: PreviewRatingOverlayProps) {
|
||||
if (hidden || !children) return null
|
||||
|
||||
const bottomPx = raised
|
||||
? PREVIEW_RATING_BASE_BOTTOM_PX + PREVIEW_SCRUBBER_HEIGHT_PX
|
||||
: PREVIEW_RATING_BASE_BOTTOM_PX
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-1.5 z-40 transition-[bottom] duration-150"
|
||||
style={{ bottom: `${bottomPx}px` }}
|
||||
>
|
||||
<div className="pointer-events-auto flex items-end">
|
||||
<div className="absolute right-1.5 top-1.5 z-40">
|
||||
<div className="pointer-events-auto flex items-start justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1180
frontend/src/components/ui/RatingOverlay.tsx
Normal file
1180
frontend/src/components/ui/RatingOverlay.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ type RecorderSettings = {
|
||||
useMyFreeCamsWatcher?: boolean
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||
blurPreviews?: boolean
|
||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
@ -60,6 +61,7 @@ const DEFAULTS: RecorderSettings = {
|
||||
useMyFreeCamsWatcher: false,
|
||||
autoDeleteSmallDownloads: true,
|
||||
autoDeleteSmallDownloadsBelowMB: 200,
|
||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||
blurPreviews: false,
|
||||
teaserPlayback: 'hover',
|
||||
teaserAudio: false,
|
||||
@ -397,6 +399,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
|
||||
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
||||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||||
autoDeleteSmallDownloadsKeepFavorites:
|
||||
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
|
||||
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
||||
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
||||
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
||||
@ -688,6 +692,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
0,
|
||||
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
|
||||
)
|
||||
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
|
||||
const blurPreviews = !!value.blurPreviews
|
||||
const teaserPlayback =
|
||||
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
|
||||
@ -720,6 +725,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
useMyFreeCamsWatcher,
|
||||
autoDeleteSmallDownloads,
|
||||
autoDeleteSmallDownloadsBelowMB,
|
||||
autoDeleteSmallDownloadsKeepFavorites,
|
||||
blurPreviews,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
@ -1321,6 +1327,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</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>
|
||||
|
||||
<LabeledSwitch
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user