added rating

This commit is contained in:
Linrador 2026-04-28 11:02:08 +02:00
parent 14412eab4c
commit edf4894790
30 changed files with 2419 additions and 1989 deletions

View File

@ -3,14 +3,11 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image" "image"
"image/draw" "image/draw"
"image/jpeg"
"math" "math"
"net/http" "net/http"
"os" "os"
@ -43,9 +40,23 @@ type analyzeVideoResp struct {
Goal string `json:"goal,omitempty"` Goal string `json:"goal,omitempty"`
Hits []analyzeHit `json:"hits"` Hits []analyzeHit `json:"hits"`
Segments []aiSegmentMeta `json:"segments,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"`
Rating *aiRatingMeta `json:"rating,omitempty"`
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
@ -136,23 +147,10 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima
return out, nil return out, nil
} }
func encodeImageJPEGBase64(img image.Image) (string, error) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
_ = ctx _ = ctx
b64, err := encodeImageJPEGBase64(img) results, err := detectNSFWFromImage(img)
if err != nil {
return nil, err
}
results, err := detectNSFWFromBase64(b64)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -176,10 +174,10 @@ func nsfwLabelPriority(label string) int {
case "anus_exposed": case "anus_exposed":
return 800 return 800
case "male_genitalia_exposed": case "buttocks_exposed":
return 700 return 700
case "buttocks_exposed": case "male_genitalia_exposed":
return 600 return 600
case case
@ -339,7 +337,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel() defer cancel()
var hits []analyzeHit var hits []analyzeHit
@ -365,11 +363,17 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
durationSec, _ := durationSecondsForAnalyze(ctx, outPath) durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if req.Goal == "nsfw" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{ ai := &aiAnalysisMeta{
Goal: req.Goal, Goal: req.Goal,
Mode: req.Mode, Mode: req.Mode,
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(), AnalyzedAtUnix: time.Now().Unix(),
} }
@ -383,6 +387,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
Goal: req.Goal, Goal: req.Goal,
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
Rating: rating,
}) })
} }
@ -651,6 +656,121 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
return out 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 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 durationSec <= 0 || len(segments) == 0 {
return r
}
videoMinutes := math.Max(durationSec/60.0, 0.25)
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 segDur <= 0 {
continue
}
sev := nsfwSegmentSeverityWeight(s.Label)
conf := clamp01(s.Score)
// Confidence beeinflusst, dominiert aber nicht.
weight := sev * (0.65 + 0.35*conf)
totalFlagged += segDur
totalWeighted += segDur * weight
confSum += conf
n++
if segDur > longest {
longest = segDur
}
}
if n == 0 {
return r
}
coverageRatio := clamp01(totalFlagged / durationSec)
weightedCoverageRatio := clamp01(totalWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / float64(n)
// 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)
raw := 0.55*coverageNorm +
0.20*frequencyNorm +
0.15*longestNorm +
0.10*confNorm
score := math.Round(clamp01(raw)*1000) / 10 // 0..100 mit 1 Nachkommastelle
stars := starsFromNSFWScore(score)
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 r
}
func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta {
if len(hits) == 0 || duration <= 0 { if len(hits) == 0 || duration <= 0 {
return []aiSegmentMeta{} return []aiSegmentMeta{}

View File

@ -811,7 +811,7 @@ func ensureAnalyzeForVideoCtx(
goal = "nsfw" goal = "nsfw"
} }
actx, cancel := context.WithTimeout(ctx, 45*time.Second) actx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel() defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath) durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
@ -822,11 +822,17 @@ func ensureAnalyzeForVideoCtx(
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if goal == "nsfw" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{ ai := &aiAnalysisMeta{
Goal: goal, Goal: goal,
Mode: "sprite", Mode: "sprite",
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(), AnalyzedAtUnix: time.Now().Unix(),
} }
@ -1416,11 +1422,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if goal == "nsfw" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{ ai := &aiAnalysisMeta{
Goal: goal, Goal: goal,
Mode: "sprite", Mode: "sprite",
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(), AnalyzedAtUnix: time.Now().Unix(),
} }

View File

@ -14,11 +14,11 @@ import (
) )
const ( const (
previewSpriteCols = 10 previewSpriteCols = 20
previewSpriteRows = 8 previewSpriteRows = 12
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 160 previewSpriteCellW = 320
previewSpriteCellH = 90 previewSpriteCellH = 180
) )
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
@ -30,7 +30,7 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
return 5 return 5
} }
// 80 Zellen = 79 Abstände. // 120 Zellen = 119 Abstände.
// Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg // Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
// nicht "hinter" dem letzten gültigen Frame sampelt. // nicht "hinter" dem letzten gültigen Frame sampelt.
usableDur := durationSec - 0.10 usableDur := durationSec - 0.10
@ -135,7 +135,7 @@ func generatePreviewSpriteJPG(
"-frames:v", "1", "-frames:v", "1",
"-update", "1", "-update", "1",
"-c:v", "mjpeg", "-c:v", "mjpeg",
"-q:v", "4", "-q:v", "3",
tmpPath, tmpPath,
) )

View File

@ -1,114 +1,90 @@
{ {
"items": [ "items": [
{ {
"modelKey": "xdaisyx", "modelKey": "jam_cream",
"url": "https://www.myfreecams.com/#xDaisyx", "url": "https://www.myfreecams.com/#Jam_cream",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1777292591836, "nextProbeAtMs": 1777366547688,
"currentShow": "offline", "currentShow": "offline",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "wekittycluby", "modelKey": "sharitaklemme",
"url": "https://chaturbate.com/wekittycluby/", "url": "https://chaturbate.com/sharitaklemme/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
"source": "watched"
},
{
"modelKey": "la__melanie",
"url": "https://chaturbate.com/la__melanie/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/la__melanie.jpg",
"source": "watched"
},
{
"modelKey": "adrean_sofi",
"url": "https://chaturbate.com/adrean_sofi/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg",
"source": "watched"
},
{
"modelKey": "extasy_maya",
"url": "https://chaturbate.com/extasy_maya/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg",
"source": "watched"
},
{
"modelKey": "rita__shy",
"url": "https://chaturbate.com/rita__shy/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg",
"source": "watched"
},
{
"modelKey": "shachatte",
"url": "https://chaturbate.com/shachatte/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/shachatte.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "carolecronan", "modelKey": "carolecronan",
"url": "https://chaturbate.com/carolecronan/", "url": "https://chaturbate.com/carolecronan/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "away", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "lora_lifelover", "modelKey": "dawnaullah",
"url": "https://chaturbate.com/lora_lifelover/", "url": "https://chaturbate.com/dawnaullah/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/dawnaullah.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "phantomlace", "modelKey": "sexdrugg666",
"url": "https://chaturbate.com/phantomlace/", "url": "https://chaturbate.com/sexdrugg666/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/sexdrugg666.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "aurora_the_ballerina", "modelKey": "yuri_kai",
"url": "https://chaturbate.com/aurora_the_ballerina/", "url": "https://chaturbate.com/yuri_kai/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "away",
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/yuri_kai.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "anabel054", "modelKey": "kaliana_ggh",
"url": "https://chaturbate.com/anabel054/", "url": "https://chaturbate.com/kaliana_ggh/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/kaliana_ggh.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "phqqipi", "modelKey": "vivian_qwerty",
"url": "https://chaturbate.com/phqqipi/", "url": "https://chaturbate.com/vivian_qwerty/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vivian_qwerty.jpg",
"source": "watched"
},
{
"modelKey": "my_mia_",
"url": "https://chaturbate.com/my_mia_/",
"mode": "wait_public",
"currentShow": "away",
"imageUrl": "https://thumb.live.mmcdn.com/ri/my_mia_.jpg",
"source": "watched"
},
{
"modelKey": "kusssecka",
"url": "https://chaturbate.com/kusssecka/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/kusssecka.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "evewilder", "modelKey": "rafaelaloghry",
"url": "https://chaturbate.com/evewilder/", "url": "https://chaturbate.com/rafaelaloghry/",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1777292606854, "nextProbeAtMs": 1777366568700,
"currentShow": "unknown", "currentShow": "unknown",
"source": "watched" "source": "watched"
} }

View File

@ -1207,6 +1207,16 @@ func uniqueDestPath(dstDir, file string) (string, error) {
return dst, nil return dst, nil
} }
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func idFromVideoPath(videoPath string) string { func idFromVideoPath(videoPath string) string {
return canonicalAssetIDFromName(videoPath) return canonicalAssetIDFromName(videoPath)
} }

View File

@ -115,6 +115,7 @@ type aiAnalysisMeta struct {
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Hits []analyzeHit `json:"hits,omitempty"` Hits []analyzeHit `json:"hits,omitempty"`
Segments []aiSegmentMeta `json:"segments,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"`
Rating *aiRatingMeta `json:"rating,omitempty"`
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
} }

View File

@ -154,34 +154,7 @@ func initNSFWDetector() error {
return nil return nil
} }
func closeNSFWDetector() error { func detectNSFWFromImage(img image.Image) ([]NsfwFrameResult, error) {
globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock()
if !globalNSFW.initialized {
return nil
}
if globalNSFW.session != nil {
globalNSFW.session.Destroy()
globalNSFW.session = nil
}
if globalNSFW.outputTensor != nil {
globalNSFW.outputTensor.Destroy()
globalNSFW.outputTensor = nil
}
if globalNSFW.inputTensor != nil {
globalNSFW.inputTensor.Destroy()
globalNSFW.inputTensor = nil
}
ort.DestroyEnvironment()
globalNSFW.initialized = false
return nil
}
func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
globalNSFW.mu.Lock() globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock() defer globalNSFW.mu.Unlock()
@ -189,11 +162,6 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
return nil, fmt.Errorf("nsfw detector nicht initialisiert") return nil, fmt.Errorf("nsfw detector nicht initialisiert")
} }
img, err := decodeBase64Image(imageB64)
if err != nil {
return nil, err
}
fillInputTensor(globalNSFW.inputTensor.GetData(), img) fillInputTensor(globalNSFW.inputTensor.GetData(), img)
if err := globalNSFW.session.Run(); err != nil { if err := globalNSFW.session.Run(); err != nil {
@ -231,6 +199,41 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
return out, nil return out, nil
} }
func closeNSFWDetector() error {
globalNSFW.mu.Lock()
defer globalNSFW.mu.Unlock()
if !globalNSFW.initialized {
return nil
}
if globalNSFW.session != nil {
globalNSFW.session.Destroy()
globalNSFW.session = nil
}
if globalNSFW.outputTensor != nil {
globalNSFW.outputTensor.Destroy()
globalNSFW.outputTensor = nil
}
if globalNSFW.inputTensor != nil {
globalNSFW.inputTensor.Destroy()
globalNSFW.inputTensor = nil
}
ort.DestroyEnvironment()
globalNSFW.initialized = false
return nil
}
func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
img, err := decodeBase64Image(imageB64)
if err != nil {
return nil, err
}
return detectNSFWFromImage(img)
}
func decodeBase64Image(imageB64 string) (image.Image, error) { func decodeBase64Image(imageB64 string) (image.Image, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64)) raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64))
if err != nil { if err != nil {

View File

@ -1877,6 +1877,55 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return items, sortedIdx return items, sortedIdx
} }
func applyGeneratedMetaToRecordJobMeta(j *RecordJob) {
if j == nil {
return
}
outPath := strings.TrimSpace(j.Output)
if outPath == "" {
return
}
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return
}
id := canonicalAssetIDFromName(outPath)
id = strings.TrimSpace(id)
if id == "" {
return
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return
}
meta, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || meta == nil {
return
}
rv := reflect.ValueOf(j)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return
}
sv := rv.Elem()
if !sv.IsValid() || sv.Kind() != reflect.Struct {
return
}
fv := sv.FieldByName("Meta")
if !fv.IsValid() || !fv.CanSet() {
return
}
setStructFieldJSONMap(fv, metaMapFromAny(meta))
}
// ---------------- Done meta + list ---------------- // ---------------- Done meta + list ----------------
func activePostworkBasenameSet() map[string]struct{} { func activePostworkBasenameSet() map[string]struct{} {
@ -2319,10 +2368,11 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
c := *base c := *base
// Sprite-Infos aus Dateisystem/meta ergänzen // vollständiges generated meta.json laden (inkl. analysis.ai.rating)
applyPreviewSpriteTruthToRecordJobMeta(&c) applyGeneratedMetaToRecordJobMeta(&c)
// WICHTIG: completed-Flags für Meta/Thumb/Teaser/Sprites/Analyze setzen // danach gezielte Wahrheiten/Fallbacks drüberlegen
applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c) applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c) out = append(out, &c)
@ -2435,18 +2485,15 @@ func releaseFileForMutation(file string) {
return return
} }
assetsReleased := requestAssetsTaskSkip(file)
// Enrich für genau diese Datei stoppen / aus Queue nehmen // Enrich für genau diese Datei stoppen / aus Queue nehmen
cancelDeferredEnrichForFile(file) cancelDeferredEnrichForFile(file)
// WICHTIG: // releaseFileTasks(file) macht jetzt das gezielte Freigeben/Abbrechen
// releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen. // für genau diese Datei, inkl. generate-assets Skip pro Datei.
// Falls das dort aktuell passiert: rausnehmen.
resp := releaseFileTasks(file) resp := releaseFileTasks(file)
// Kurz warten, damit Handles der betroffenen Datei schließen // Kurz warten, damit Handles der betroffenen Datei schließen
if assetsReleased || resp.ReleasedAny { if resp.ReleasedAny {
time.Sleep(250 * time.Millisecond) time.Sleep(250 * time.Millisecond)
} }
} }

View File

@ -149,9 +149,9 @@ func main() {
defer cancel() defer cancel()
if _, err := refreshChaturbateSnapshotNow(ctx); err != nil { if _, err := refreshChaturbateSnapshotNow(ctx); err != nil {
fmt.Println("⚠️ [chaturbate] startup snapshot failed:", err) fmt.Println("⚠️ Chaturbate API failed:", err)
} else { } else {
fmt.Println("✅ [chaturbate] startup snapshot loaded") fmt.Println("✅ Chaturbate API geladen")
} }
}() }()
} }

View File

@ -355,16 +355,6 @@ func splitTimeoutForSegments(count int) time.Duration {
return d return d
} }
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error { func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error {
b, err := json.Marshal(ev) b, err := json.Marshal(ev)
if err != nil { if err != nil {

View File

@ -76,6 +76,7 @@ type taskStateEvent struct {
Type string `json:"type"` // "task_state" Type string `json:"type"` // "task_state"
GenerateAssetsRunning bool `json:"generateAssetsRunning"` GenerateAssetsRunning bool `json:"generateAssetsRunning"`
RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"` RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"`
CheckVideosRunning bool `json:"checkVideosRunning"`
CleanupRunning bool `json:"cleanupRunning"` CleanupRunning bool `json:"cleanupRunning"`
AnyRunning bool `json:"anyRunning"` AnyRunning bool `json:"anyRunning"`
TS int64 `json:"ts"` TS int64 `json:"ts"`
@ -117,17 +118,56 @@ func publishFinishedPostworkStateForJob(
}) })
} }
func anyGenerateAssetsTaskRunning() bool {
for _, st := range snapshotAssetsJobs() {
if st.Queued || st.Running {
return true
}
}
return false
}
func anyRegenerateAssetsTaskRunning() bool {
for _, job := range getRegenerateAssetsJobsSnapshot() {
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
if state == "queued" || state == "running" {
return true
}
}
return false
}
func anyCheckVideosTaskRunning() bool {
for _, st := range snapshotCheckVideosJobs() {
if st.Queued || st.Running {
return true
}
}
return false
}
func anyCleanupTaskRunning() bool {
for _, st := range snapshotCleanupJobs() {
if st.Queued || st.Running {
return true
}
}
return false
}
func publishTaskState() { func publishTaskState() {
assets := getGenerateAssetsTaskStatus() assetsRunning := anyGenerateAssetsTaskRunning()
regenerate := snapshotRegenerateAssetsTaskState() regenerateRunning := anyRegenerateAssetsTaskRunning()
cleanup := snapshotCleanupTaskState() checkVideosRunning := anyCheckVideosTaskRunning()
cleanupRunning := anyCleanupTaskRunning()
ev := taskStateEvent{ ev := taskStateEvent{
Type: "task_state", Type: "task_state",
GenerateAssetsRunning: assets.Running, GenerateAssetsRunning: assetsRunning,
RegenerateAssetsRunning: regenerate.Running, RegenerateAssetsRunning: regenerateRunning,
CleanupRunning: cleanup.Running, CheckVideosRunning: checkVideosRunning,
AnyRunning: assets.Running || regenerate.Running || cleanup.Running, CleanupRunning: cleanupRunning,
AnyRunning: assetsRunning || regenerateRunning || checkVideosRunning || cleanupRunning,
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
} }
@ -524,11 +564,7 @@ func jobsSnapshotJSON() []byte {
} }
func assetsSnapshotJSON() []byte { func assetsSnapshotJSON() []byte {
assetsTaskMu.Lock() b, _ := json.Marshal(snapshotAssetsJobs())
st := assetsTaskState
assetsTaskMu.Unlock()
b, _ := json.Marshal(st)
return b return b
} }

View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -19,6 +20,7 @@ import (
// --------------------------- // ---------------------------
type AssetsTaskState struct { type AssetsTaskState struct {
ID string `json:"id"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
Running bool `json:"running"` Running bool `json:"running"`
QueuedAt time.Time `json:"queuedAt"` QueuedAt time.Time `json:"queuedAt"`
@ -45,12 +47,24 @@ type assetsPhaseSelection struct {
Analyze bool Analyze bool
} }
var assetsTaskMu sync.Mutex type generateAssetsRequest struct {
var assetsTaskState AssetsTaskState Meta *bool `json:"meta,omitempty"`
var assetsTaskCancel context.CancelFunc Thumb *bool `json:"thumb,omitempty"`
Teaser *bool `json:"teaser,omitempty"`
Sprites *bool `json:"sprites,omitempty"`
Analyze *bool `json:"analyze,omitempty"`
}
var assetsTaskFileCancels = map[string]context.CancelFunc{} type assetsTaskJob struct {
var assetsTaskSkipFiles = map[string]struct{}{} State AssetsTaskState
Cancel context.CancelFunc
FileCancels map[string]context.CancelFunc
SkipFiles map[string]struct{}
Selection assetsPhaseSelection
}
var assetsJobsMu sync.Mutex
var assetsJobs = map[string]*assetsTaskJob{}
func selectedAssetsPhasesFromSettings() assetsPhaseSelection { func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
s := getSettings() s := getSettings()
@ -63,199 +77,258 @@ func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
} }
} }
func assetsTaskFileKey(file string) string { func selectedAssetsPhasesFromRequest(req *generateAssetsRequest) assetsPhaseSelection {
return strings.ToLower(strings.TrimSpace(filepath.Base(file))) sel := selectedAssetsPhasesFromSettings()
}
func resetAssetsTaskFileControlLocked() { if req == nil {
assetsTaskFileCancels = map[string]context.CancelFunc{} return sel
assetsTaskSkipFiles = map[string]struct{}{}
}
func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) {
key := assetsTaskFileKey(file)
if key == "" {
return
} }
assetsTaskMu.Lock() if req.Meta != nil {
assetsTaskFileCancels[key] = cancel sel.Meta = *req.Meta
assetsTaskMu.Unlock() }
} if req.Thumb != nil {
sel.Thumb = *req.Thumb
func clearAssetsTaskFileControl(file string) { }
key := assetsTaskFileKey(file) if req.Teaser != nil {
if key == "" { sel.Teaser = *req.Teaser
return }
if req.Sprites != nil {
sel.Sprites = *req.Sprites
}
if req.Analyze != nil {
sel.Analyze = *req.Analyze
} }
assetsTaskMu.Lock() return sel
delete(assetsTaskFileCancels, key)
delete(assetsTaskSkipFiles, key)
assetsTaskMu.Unlock()
}
func requestAssetsTaskSkip(file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
var cancel context.CancelFunc
assetsTaskMu.Lock()
assetsTaskSkipFiles[key] = struct{}{}
cancel = assetsTaskFileCancels[key]
assetsTaskMu.Unlock()
if cancel != nil {
cancel()
return true
}
return false
}
func isAssetsTaskSkipRequested(file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
assetsTaskMu.Lock()
_, ok := assetsTaskSkipFiles[key]
assetsTaskMu.Unlock()
return ok
} }
func (p assetsPhaseSelection) Any() bool { func (p assetsPhaseSelection) Any() bool {
return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze
} }
// updateAssetsState mutiert den State atomar und triggert danach SSE notify. func assetsTaskFileKey(file string) string {
// notifyAssetsChanged() muss außerhalb des Locks passieren. return strings.ToLower(strings.TrimSpace(filepath.Base(file)))
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { }
assetsTaskMu.Lock()
fn(&assetsTaskState) func snapshotAssetsJobs() []AssetsTaskState {
st := assetsTaskState assetsJobsMu.Lock()
assetsTaskMu.Unlock() defer assetsJobsMu.Unlock()
now := time.Now()
out := make([]AssetsTaskState, 0, len(assetsJobs))
for id, job := range assetsJobs {
if job == nil {
delete(assetsJobs, id)
continue
}
st := job.State
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
delete(assetsJobs, id)
continue
}
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool {
ai := out[i].QueuedAt
aj := out[j].QueuedAt
if ai.IsZero() {
ai = out[i].StartedAt
}
if aj.IsZero() {
aj = out[j].StartedAt
}
return ai.Before(aj)
})
return out
}
func updateAssetsJobState(jobID string, fn func(st *AssetsTaskState)) (AssetsTaskState, bool) {
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job == nil {
assetsJobsMu.Unlock()
return AssetsTaskState{}, false
}
fn(&job.State)
st := job.State
assetsJobsMu.Unlock()
notifyAssetsChanged() notifyAssetsChanged()
publishTaskState() publishTaskState()
return st return st, true
} }
func snapshotAssetsState() AssetsTaskState { func removeAssetsJob(jobID string) {
assetsTaskMu.Lock() assetsJobsMu.Lock()
st := assetsTaskState delete(assetsJobs, jobID)
assetsTaskMu.Unlock() assetsJobsMu.Unlock()
return st
notifyAssetsChanged()
publishTaskState()
} }
func getGenerateAssetsTaskStatus() AssetsTaskState { func registerAssetsJobFileCancel(jobID, file string, cancel context.CancelFunc) {
return snapshotAssetsState() key := assetsTaskFileKey(file)
if key == "" {
return
}
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job != nil {
if job.FileCancels == nil {
job.FileCancels = map[string]context.CancelFunc{}
}
job.FileCancels[key] = cancel
}
assetsJobsMu.Unlock()
}
func clearAssetsJobFileControl(jobID, file string) {
key := assetsTaskFileKey(file)
if key == "" {
return
}
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job != nil {
delete(job.FileCancels, key)
delete(job.SkipFiles, key)
}
assetsJobsMu.Unlock()
}
func requestAssetsJobSkip(jobID, file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
var (
cancel context.CancelFunc
marked bool
)
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job != nil {
if job.SkipFiles == nil {
job.SkipFiles = map[string]struct{}{}
}
job.SkipFiles[key] = struct{}{}
cancel = job.FileCancels[key]
marked = true
}
assetsJobsMu.Unlock()
if cancel != nil {
cancel()
}
return marked
}
func isAssetsJobSkipRequested(jobID, file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job == nil {
assetsJobsMu.Unlock()
return false
}
_, ok := job.SkipFiles[key]
assetsJobsMu.Unlock()
return ok
} }
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET bleibt als Fallback/Debug möglich (UI nutzt SSE) writeJSON(w, http.StatusOK, snapshotAssetsJobs())
st := snapshotAssetsState()
writeJSON(w, http.StatusOK, st)
return return
case http.MethodPost: case http.MethodPost:
assetsTaskMu.Lock() var req generateAssetsRequest
if assetsTaskState.Running || assetsTaskState.Queued { if r.Body != nil && r.ContentLength != 0 {
st := assetsTaskState _ = json.NewDecoder(r.Body).Decode(&req)
assetsTaskMu.Unlock() }
writeJSON(w, http.StatusOK, st) sel := selectedAssetsPhasesFromRequest(&req)
return
jobID := newTaskID("generate-assets")
ctx, cancel := context.WithCancel(context.Background())
job := &assetsTaskJob{
State: AssetsTaskState{
ID: jobID,
Queued: true,
Running: false,
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
Error: "",
CurrentFile: "",
CurrentQueue: "",
CurrentPhase: "",
CurrentLabel: "",
Text: "Wartet…",
},
Cancel: cancel,
FileCancels: map[string]context.CancelFunc{},
SkipFiles: map[string]struct{}{},
Selection: sel,
} }
ctx, cancel := context.WithCancel(context.Background()) assetsJobsMu.Lock()
assetsTaskCancel = cancel assetsJobs[jobID] = job
assetsTaskState = AssetsTaskState{ assetsJobsMu.Unlock()
Queued: true,
Running: false,
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
Error: "",
CurrentFile: "",
CurrentQueue: "",
CurrentPhase: "",
CurrentLabel: "",
Text: "Wartet…",
}
resetAssetsTaskFileControlLocked()
st := assetsTaskState
assetsTaskMu.Unlock()
notifyAssetsChanged() notifyAssetsChanged()
publishTaskState() publishTaskState()
go func() { go runGenerateMissingAssetsJob(jobID, ctx)
if err := acquireExclusiveTask(ctx); err != nil {
now := time.Now()
updateAssetsState(func(st *AssetsTaskState) {
st.Queued = false
st.Running = false
st.FinishedAt = &now
if errors.Is(err, context.Canceled) {
st.Error = "Abgebrochen"
} else {
st.Error = err.Error()
}
})
return
}
defer releaseExclusiveTask()
updateAssetsState(func(st *AssetsTaskState) { writeJSON(w, http.StatusOK, job.State)
st.Queued = false
st.Running = true
st.StartedAt = time.Now()
st.Text = ""
})
runGenerateMissingAssets(ctx)
}()
writeJSON(w, http.StatusOK, st)
return return
case http.MethodDelete: case http.MethodDelete:
assetsTaskMu.Lock() id := strings.TrimSpace(r.URL.Query().Get("id"))
cancel := assetsTaskCancel if id == "" {
running := assetsTaskState.Running http.Error(w, "id fehlt", http.StatusBadRequest)
queued := assetsTaskState.Queued return
assetsTaskMu.Unlock() }
if (!running && !queued) || cancel == nil { assetsJobsMu.Lock()
job := assetsJobs[id]
assetsJobsMu.Unlock()
if job == nil || job.Cancel == nil {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
// canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber job.Cancel()
cancel() w.WriteHeader(http.StatusNoContent)
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
st := updateAssetsState(func(st *AssetsTaskState) {
if st.Running || st.Queued {
st.Queued = false
st.Running = false
st.Error = "Abgebrochen"
}
})
writeJSON(w, http.StatusOK, st)
return return
default: default:
@ -397,18 +470,30 @@ func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth {
return truth return truth
} }
func runGenerateMissingAssets(ctx context.Context) { func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
assetsJobsMu.Lock()
job := assetsJobs[jobID]
if job == nil {
assetsJobsMu.Unlock()
return
}
sel := job.Selection
assetsJobsMu.Unlock()
// Worker-Ende: CancelFunc zurücksetzen (pro Run) // Worker-Ende: CancelFunc zurücksetzen (pro Run)
defer func() { defer func() {
assetsTaskMu.Lock() assetsJobsMu.Lock()
assetsTaskCancel = nil job := assetsJobs[jobID]
assetsTaskMu.Unlock() if job != nil {
job.Cancel = nil
}
assetsJobsMu.Unlock()
}() }()
finishWithErr := func(err error) { finishWithErr := func(err error) {
now := time.Now() now := time.Now()
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Queued = false st.Queued = false
st.Running = false st.Running = false
st.FinishedAt = &now st.FinishedAt = &now
@ -429,19 +514,42 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
}) })
assetsTaskMu.Lock() assetsJobsMu.Lock()
resetAssetsTaskFileControlLocked() job := assetsJobs[jobID]
assetsTaskMu.Unlock() if job != nil {
job.FileCancels = map[string]context.CancelFunc{}
job.SkipFiles = map[string]struct{}{}
}
assetsJobsMu.Unlock()
time.AfterFunc(4*time.Second, func() {
removeAssetsJob(jobID)
})
} }
s := getSettings() // Wichtig:
sel := selectedAssetsPhasesFromSettings() // Die Phasen kommen jetzt bereits von außen (Request-Overrides oder gespeicherte Settings)
if !sel.Any() { if !sel.Any() {
finishWithErr(nil) finishWithErr(nil)
return return
} }
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err := acquireExclusiveTask(ctx); err != nil {
finishWithErr(err)
return
}
defer releaseExclusiveTask()
updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Queued = false
st.Running = true
st.StartedAt = time.Now()
st.Text = ""
})
settings := getSettings()
doneAbs, err := resolvePathRelativeToApp(settings.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
return return
@ -507,7 +615,7 @@ func runGenerateMissingAssets(ctx context.Context) {
}) })
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Total = len(items) // alle Dateien, inkl. keep + skips st.Total = len(items) // alle Dateien, inkl. keep + skips
st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen
st.GeneratedThumbs = 0 st.GeneratedThumbs = 0
@ -538,16 +646,16 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
fileCtx, fileCancel := context.WithCancel(ctx) fileCtx, fileCancel := context.WithCancel(ctx)
registerAssetsTaskFileCancel(it.name, fileCancel) registerAssetsJobFileCancel(jobID, it.name, fileCancel)
finishFileControl := func() { finishFileControl := func() {
fileCancel() fileCancel()
clearAssetsTaskFileControl(it.name) clearAssetsJobFileControl(jobID, it.name)
} }
skipCurrentFile := func() { skipCurrentFile := func() {
clearAssetsTaskFinishedPostworkStates(it.name, sel) clearAssetsTaskFinishedPostworkStates(it.name, sel)
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Done++ st.Done++
st.Skipped++ st.Skipped++
st.CurrentFile = "" st.CurrentFile = ""
@ -560,7 +668,7 @@ func runGenerateMissingAssets(ctx context.Context) {
handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) { handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) {
if stepErr == nil { if stepErr == nil {
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
return true, false return true, false
} }
return false, false return false, false
@ -568,7 +676,7 @@ func runGenerateMissingAssets(ctx context.Context) {
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
// globaler Task wurde beendet // globaler Task wurde beendet
if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) { if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) {
finishFileControl() finishFileControl()
finishWithErr(context.Canceled) finishWithErr(context.Canceled)
return false, true return false, true
@ -579,18 +687,18 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
// Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben // Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
return true, false return true, false
} }
return false, false return false, false
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
}) })
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
@ -601,7 +709,7 @@ func runGenerateMissingAssets(ctx context.Context) {
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name, sel) clearAssetsTaskFinishedPostworkStates(it.name, sel)
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Done++ st.Done++
st.Skipped++ st.Skipped++
st.CurrentFile = "" st.CurrentFile = ""
@ -641,7 +749,7 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done++ st.Done++
st.CurrentFile = "" st.CurrentFile = ""
@ -668,7 +776,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// META // META
// ---------------- // ----------------
if needMeta { if needMeta {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "postwork" st.CurrentQueue = "postwork"
st.CurrentPhase = "meta" st.CurrentPhase = "meta"
@ -684,6 +792,7 @@ func runGenerateMissingAssets(ctx context.Context) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
if merr != nil || !okMeta { if merr != nil || !okMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
} else { } else {
@ -695,7 +804,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// THUMB // THUMB
// ---------------- // ----------------
if needThumb { if needThumb {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "postwork" st.CurrentQueue = "postwork"
st.CurrentPhase = "thumb" st.CurrentPhase = "thumb"
@ -711,6 +820,7 @@ func runGenerateMissingAssets(ctx context.Context) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
if terr != nil { if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).ThumbReady { } else if assetsTaskTruthForVideo(id, it.path).ThumbReady {
@ -727,7 +837,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// TEASER // TEASER
// ---------------- // ----------------
if needTeaser { if needTeaser {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "postwork" st.CurrentQueue = "postwork"
st.CurrentPhase = "teaser" st.CurrentPhase = "teaser"
@ -743,6 +853,7 @@ func runGenerateMissingAssets(ctx context.Context) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
if terr != nil { if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady { } else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
@ -759,7 +870,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// SPRITES // SPRITES
// ---------------- // ----------------
if needSprites { if needSprites {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "postwork" st.CurrentQueue = "postwork"
st.CurrentPhase = "sprites" st.CurrentPhase = "sprites"
@ -775,6 +886,7 @@ func runGenerateMissingAssets(ctx context.Context) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
if serr != nil { if serr != nil {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).SpritesReady { } else if assetsTaskTruthForVideo(id, it.path).SpritesReady {
@ -788,7 +900,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// ANALYZE // ANALYZE
// ---------------- // ----------------
if needAnalyze { if needAnalyze {
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "enrich" st.CurrentQueue = "enrich"
st.CurrentPhase = "analyze" st.CurrentPhase = "analyze"
@ -804,6 +916,7 @@ func runGenerateMissingAssets(ctx context.Context) {
skipCurrentFile() skipCurrentFile()
continue continue
} }
if aerr != nil { if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
@ -813,7 +926,7 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsJobState(jobID, func(st *AssetsTaskState) {
if thumbGenerated { if thumbGenerated {
st.GeneratedThumbs++ st.GeneratedThumbs++
} }

View File

@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
@ -37,21 +38,6 @@ type tailBlackoutResult struct {
Samples []tailBlackoutSample `json:"samples,omitempty"` Samples []tailBlackoutSample `json:"samples,omitempty"`
} }
type checkVideosTaskState struct {
mu sync.Mutex
queued bool
running bool
done int
total int
text string
currentFile string
err string
queuedAt time.Time
startedAt time.Time
finishedAt time.Time
cancel context.CancelFunc
}
func shortTaskFilename(name string, max int) string { func shortTaskFilename(name string, max int) string {
s := strings.TrimSpace(name) s := strings.TrimSpace(name)
if s == "" { if s == "" {
@ -63,39 +49,74 @@ func shortTaskFilename(name string, max int) string {
return "…" + s[len(s)-(max-1):] return "…" + s[len(s)-(max-1):]
} }
func snapshotCheckVideosTaskState() CheckVideosTaskState { func snapshotCheckVideosJobs() []CheckVideosTaskState {
checkVideosTask.mu.Lock() checkVideosJobsMu.Lock()
defer checkVideosTask.mu.Unlock() defer checkVideosJobsMu.Unlock()
// Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben now := time.Now()
if !checkVideosTask.queued && out := make([]CheckVideosTaskState, 0, len(checkVideosJobs))
!checkVideosTask.running &&
!checkVideosTask.finishedAt.IsZero() && for id, job := range checkVideosJobs {
time.Since(checkVideosTask.finishedAt) > 4*time.Second { if job == nil {
return CheckVideosTaskState{} delete(checkVideosJobs, id)
continue
}
st := job.State
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
delete(checkVideosJobs, id)
continue
}
out = append(out, st)
} }
out := CheckVideosTaskState{ sort.Slice(out, func(i, j int) bool {
Queued: checkVideosTask.queued, ai := out[i].QueuedAt
Running: checkVideosTask.running, aj := out[j].QueuedAt
QueuedAt: checkVideosTask.queuedAt, if ai.IsZero() {
StartedAt: checkVideosTask.startedAt, ai = out[i].StartedAt
Text: checkVideosTask.text, }
Error: checkVideosTask.err, if aj.IsZero() {
CurrentFile: checkVideosTask.currentFile, aj = out[j].StartedAt
Done: checkVideosTask.done, }
Total: checkVideosTask.total, return ai.Before(aj)
} })
if !checkVideosTask.finishedAt.IsZero() {
finishedAt := checkVideosTask.finishedAt
out.FinishedAt = &finishedAt
}
return out return out
} }
var checkVideosTask checkVideosTaskState func updateCheckVideosJobState(jobID string, fn func(st *CheckVideosTaskState)) (CheckVideosTaskState, bool) {
checkVideosJobsMu.Lock()
job := checkVideosJobs[jobID]
if job == nil {
checkVideosJobsMu.Unlock()
return CheckVideosTaskState{}, false
}
fn(&job.State)
st := job.State
checkVideosJobsMu.Unlock()
publishTaskState()
return st, true
}
func removeCheckVideosJob(jobID string) {
checkVideosJobsMu.Lock()
delete(checkVideosJobs, jobID)
checkVideosJobsMu.Unlock()
publishTaskState()
}
type checkVideosTaskJob struct {
State CheckVideosTaskState
Cancel context.CancelFunc
}
var checkVideosJobsMu sync.Mutex
var checkVideosJobs = map[string]*checkVideosTaskJob{}
func clampFloat(v, lo, hi float64) float64 { func clampFloat(v, lo, hi float64) float64 {
if v < lo { if v < lo {
@ -181,75 +202,85 @@ func collectFinishedVideosForCheck() ([]string, error) {
return files, nil return files, nil
} }
func finishCheckVideosTask(err error) { func finishCheckVideosTask(jobID string, err error) {
checkVideosTask.mu.Lock() now := time.Now()
checkVideosTask.queued = false
checkVideosTask.running = false
checkVideosTask.cancel = nil
checkVideosTask.finishedAt = time.Now()
errText := strings.TrimSpace(func() string { updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
if err == nil { st.Queued = false
return "" st.Running = false
st.FinishedAt = &now
errText := strings.TrimSpace(func() string {
if err == nil {
return ""
}
return err.Error()
}())
cancelled :=
strings.EqualFold(errText, "abgebrochen") ||
strings.EqualFold(errText, "abbruch") ||
strings.EqualFold(errText, "cancelled") ||
strings.EqualFold(errText, "canceled")
if cancelled {
st.Error = "Abgebrochen"
st.Text = "Abgebrochen."
} else if err != nil {
st.Error = errText
st.Text = "Fehler bei der Video-Prüfung."
} else {
st.Error = ""
st.Text = "Prüfung abgeschlossen."
} }
return err.Error() })
}())
cancelled := time.AfterFunc(4*time.Second, func() {
strings.EqualFold(errText, "abgebrochen") || removeCheckVideosJob(jobID)
strings.EqualFold(errText, "abbruch") || })
strings.EqualFold(errText, "cancelled") ||
strings.EqualFold(errText, "canceled")
if cancelled {
checkVideosTask.err = ""
checkVideosTask.text = "Abgebrochen."
} else if err != nil {
checkVideosTask.err = errText
checkVideosTask.text = "Fehler bei der Video-Prüfung."
} else {
checkVideosTask.err = ""
checkVideosTask.text = "Prüfung abgeschlossen."
}
checkVideosTask.mu.Unlock()
publishTaskState()
} }
func runCheckVideosTask(ctx context.Context) { func runCheckVideosTask(jobID string, ctx context.Context) {
if err := acquireExclusiveTask(ctx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
} else {
finishCheckVideosTask(jobID, err)
}
return
}
defer releaseExclusiveTask()
files, err := collectFinishedVideosForCheck() files, err := collectFinishedVideosForCheck()
if err != nil { if err != nil {
finishCheckVideosTask(err) finishCheckVideosTask(jobID, err)
return return
} }
checkVideosTask.mu.Lock() updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
checkVideosTask.queued = false st.Queued = false
checkVideosTask.running = true st.Running = true
checkVideosTask.total = len(files) st.Total = len(files)
checkVideosTask.done = 0 st.Done = 0
checkVideosTask.startedAt = time.Now() st.StartedAt = time.Now()
checkVideosTask.text = "Prüfe Videos…" st.Text = "Prüfe Videos…"
checkVideosTask.mu.Unlock() })
publishTaskState()
var firstErr error var firstErr error
for i, file := range files { for i, file := range files {
select { select {
case <-ctx.Done(): case <-ctx.Done():
finishCheckVideosTask(fmt.Errorf("abgebrochen")) finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen"))
return return
default: default:
} }
checkVideosTask.mu.Lock() updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
checkVideosTask.currentFile = file st.CurrentFile = file
checkVideosTask.text = shortTaskFilename(file, 52) st.Text = shortTaskFilename(file, 52)
checkVideosTask.done = i st.Done = i
checkVideosTask.mu.Unlock() })
if _, err := runTailBlackoutCheck(ctx, file); err != nil { if _, err := runTailBlackoutCheck(ctx, file); err != nil {
if firstErr == nil { if firstErr == nil {
@ -257,64 +288,73 @@ func runCheckVideosTask(ctx context.Context) {
} }
} }
checkVideosTask.mu.Lock() updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) {
checkVideosTask.done = i + 1 st.Done = i + 1
checkVideosTask.mu.Unlock() })
} }
finishCheckVideosTask(firstErr) finishCheckVideosTask(jobID, firstErr)
} }
func startCheckVideosTask() error { func startCheckVideosTask() (CheckVideosTaskState, error) {
checkVideosTask.mu.Lock() jobID := newTaskID("check-videos")
defer checkVideosTask.mu.Unlock()
if checkVideosTask.running || checkVideosTask.queued {
return fmt.Errorf("task läuft bereits")
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
checkVideosTask.queued = true st := CheckVideosTaskState{
checkVideosTask.running = false ID: jobID,
checkVideosTask.done = 0 Queued: true,
checkVideosTask.total = 0 Running: false,
checkVideosTask.text = "Wartet…" Done: 0,
checkVideosTask.currentFile = "" Total: 0,
checkVideosTask.err = "" Text: "Wartet…",
checkVideosTask.queuedAt = time.Now() CurrentFile: "",
checkVideosTask.startedAt = time.Time{} Error: "",
checkVideosTask.finishedAt = time.Time{} QueuedAt: time.Now(),
checkVideosTask.cancel = cancel StartedAt: time.Time{},
FinishedAt: nil,
}
checkVideosJobsMu.Lock()
checkVideosJobs[jobID] = &checkVideosTaskJob{
State: st,
Cancel: cancel,
}
checkVideosJobsMu.Unlock()
publishTaskState() publishTaskState()
go runCheckVideosTask(ctx) go runCheckVideosTask(jobID, ctx)
return nil
return st, nil
} }
func cancelCheckVideosTask() { func cancelCheckVideosTask(jobID string) {
checkVideosTask.mu.Lock() checkVideosJobsMu.Lock()
cancel := checkVideosTask.cancel job := checkVideosJobs[jobID]
checkVideosTask.mu.Unlock() checkVideosJobsMu.Unlock()
if cancel != nil { if job != nil && job.Cancel != nil {
cancel() job.Cancel()
} }
} }
func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) { func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodPost: case http.MethodPost:
if err := startCheckVideosTask(); err != nil { st, err := startCheckVideosTask()
if err != nil {
http.Error(w, err.Error(), http.StatusConflict) http.Error(w, err.Error(), http.StatusConflict)
return return
} }
w.Header().Set("Content-Type", "application/json") writeJSON(w, http.StatusOK, st)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
return return
case http.MethodDelete: case http.MethodDelete:
cancelCheckVideosTask() id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
cancelCheckVideosTask(id)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return

View File

@ -5,12 +5,14 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
@ -43,106 +45,224 @@ type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"` BelowMB *int `json:"belowMB,omitempty"`
} }
func clearCleanupTaskState() { type cleanupTaskJob struct {
cleanupTaskMu.Lock() State CleanupTaskState
cleanupTaskState = CleanupTaskState{} Cancel context.CancelFunc
cleanupTaskMu.Unlock() }
var cleanupJobsMu sync.Mutex
var cleanupJobs = map[string]*cleanupTaskJob{}
func snapshotCleanupJobs() []CleanupTaskState {
cleanupJobsMu.Lock()
defer cleanupJobsMu.Unlock()
now := time.Now()
out := make([]CleanupTaskState, 0, len(cleanupJobs))
for id, job := range cleanupJobs {
if job == nil {
delete(cleanupJobs, id)
continue
}
st := job.State
if !st.Queued && !st.Running && st.FinishedAt != nil && now.Sub(*st.FinishedAt) > 4*time.Second {
delete(cleanupJobs, id)
continue
}
out = append(out, st)
}
return out
}
func updateCleanupJobState(jobID string, fn func(st *CleanupTaskState)) (CleanupTaskState, bool) {
cleanupJobsMu.Lock()
job := cleanupJobs[jobID]
if job == nil {
cleanupJobsMu.Unlock()
return CleanupTaskState{}, false
}
fn(&job.State)
st := job.State
cleanupJobsMu.Unlock()
publishTaskState() publishTaskState()
return st, true
}
func removeCleanupJob(jobID string) {
cleanupJobsMu.Lock()
delete(cleanupJobs, jobID)
cleanupJobsMu.Unlock()
publishTaskState()
}
func finishCleanupJob(jobID string, text string, err error) {
now := time.Now()
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = false
st.FinishedAt = &now
st.CurrentFile = ""
if err == nil {
st.Error = ""
st.Text = strings.TrimSpace(text)
if st.Total > 0 {
st.Done = st.Total
}
return
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
st.Error = "Abgebrochen"
st.Text = "Abgebrochen."
} else {
st.Error = strings.TrimSpace(err.Error())
st.Text = ""
}
})
time.AfterFunc(4*time.Second, func() {
removeCleanupJob(jobID)
})
} }
// /api/settings/cleanup (POST) // /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf // - räumt Orphans (preview/thumbs + generated) auf
func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { switch r.Method {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) case http.MethodPost:
return s := getSettings()
}
s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return
}
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
var req cleanupReq
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.BelowMB != nil {
mb = *req.BelowMB
}
if mb < 0 {
mb = 0
}
cleanupTaskMu.Lock()
if cleanupTaskState.Queued || cleanupTaskState.Running {
st := cleanupTaskState
cleanupTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
}
cleanupTaskMu.Unlock()
setCleanupTaskQueued("Wartet…")
st := snapshotCleanupTaskState()
go func(doneAbs string, mb int) {
ctx := context.Background()
if err := acquireExclusiveTask(ctx); err != nil {
setCleanupTaskError(err.Error())
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
return return
} }
defer releaseExclusiveTask()
setCleanupTaskRunning("Räume auf…") mb := int(s.AutoDeleteSmallDownloadsBelowMB)
resp := cleanupResp{} var req cleanupReq
if r.Body != nil {
if mb > 0 { _ = json.NewDecoder(r.Body).Decode(&req)
threshold := int64(mb) * 1024 * 1024 }
cleanupSmallFiles(doneAbs, threshold, &resp) if req.BelowMB != nil {
mb = *req.BelowMB
}
if mb < 0 {
mb = 0
} }
gcStats := triggerGeneratedGarbageCollectorSync() jobID := newTaskID("cleanup")
resp.GeneratedOrphansChecked = gcStats.Checked ctx, cancel := context.WithCancel(context.Background())
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
resp.DeletedBytes += gcStats.RemovedBytes st := CleanupTaskState{
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) ID: jobID,
Queued: true,
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { Running: false,
notifyDoneChanged() QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Text: "Wartet…",
Error: "",
CurrentFile: "",
Done: 0,
Total: 0,
} }
setCleanupTaskDone( cleanupJobsMu.Lock()
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), cleanupJobs[jobID] = &cleanupTaskJob{
) State: st,
Cancel: cancel,
}
cleanupJobsMu.Unlock()
time.AfterFunc(7*time.Second, func() { publishTaskState()
clearCleanupTaskState()
})
}(doneAbs, mb)
writeJSON(w, http.StatusOK, st) go func(jobID string, ctx context.Context, doneAbs string, mb int) {
if err := acquireExclusiveTask(ctx); err != nil {
finishCleanupJob(jobID, "", err)
return
}
defer releaseExclusiveTask()
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
st.StartedAt = time.Now()
st.Text = "Räume auf…"
})
resp := cleanupResp{}
if mb > 0 {
threshold := int64(mb) * 1024 * 1024
if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, &resp); err != nil {
finishCleanupJob(jobID, "", err)
return
}
}
select {
case <-ctx.Done():
finishCleanupJob(jobID, "", ctx.Err())
return
default:
}
gcStats := triggerGeneratedGarbageCollectorSync()
resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
resp.DeletedBytes += gcStats.RemovedBytes
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
notifyDoneChanged()
}
finishCleanupJob(
jobID,
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
nil,
)
}(jobID, ctx, doneAbs, mb)
writeJSON(w, http.StatusOK, st)
return
case http.MethodDelete:
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
cleanupJobsMu.Lock()
job := cleanupJobs[id]
cleanupJobsMu.Unlock()
if job != nil && job.Cancel != nil {
job.Cancel()
}
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed)
return
}
} }
func cleanupProgressText(deleted int, total int, freedBytes int64) string { func cleanupProgressText(deleted int, total int, freedBytes int64) string {
@ -154,7 +274,7 @@ func cleanupProgressText(deleted int, total int, freedBytes int64) string {
) )
} }
func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error {
isCandidate := func(name string) bool { isCandidate := func(name string) bool {
low := strings.ToLower(name) low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
@ -166,13 +286,19 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
candidates := make([]cleanupCandidate, 0, 1024) candidates := make([]cleanupCandidate, 0, 1024)
collectCandidates := func(dir string, allowSubdirs bool) { collectCandidates := func(dir string, allowSubdirs bool) error {
ents, err := os.ReadDir(dir) ents, err := os.ReadDir(dir)
if err != nil { if err != nil {
return return nil
} }
for _, e := range ents { for _, e := range ents {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
full := filepath.Join(dir, e.Name()) full := filepath.Join(dir, e.Name())
if e.IsDir() { if e.IsDir() {
@ -233,20 +359,35 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
size: fi.Size(), size: fi.Size(),
}) })
} }
return nil
} }
collectCandidates(doneAbs, true) if err := collectCandidates(doneAbs, true); err != nil {
return err
}
resp.ScannedFiles = len(candidates) resp.ScannedFiles = len(candidates)
setCleanupTaskProgress( updateCleanupJobState(jobID, func(st *CleanupTaskState) {
0, st.Queued = false
resp.ScannedFiles, st.Running = true
"", if st.StartedAt.IsZero() {
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), st.StartedAt = time.Now()
) }
st.Done = 0
st.Total = resp.ScannedFiles
st.CurrentFile = ""
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Error = ""
st.FinishedAt = nil
})
for i, c := range candidates { for i, c := range candidates {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if c.size < threshold { if c.size < threshold {
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base) id := stripHotPrefix(base)
@ -265,13 +406,22 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
} }
} }
setCleanupTaskProgress( updateCleanupJobState(jobID, func(st *CleanupTaskState) {
i+1, st.Queued = false
resp.ScannedFiles, st.Running = true
"", if st.StartedAt.IsZero() {
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), st.StartedAt = time.Now()
) }
st.Done = i + 1
st.Total = resp.ScannedFiles
st.CurrentFile = ""
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Error = ""
st.FinishedAt = nil
})
} }
return nil
} }
var generatedGCRunning int32 var generatedGCRunning int32

View File

@ -48,20 +48,27 @@ func cancelAssetsTaskForFile(file string) bool {
return false return false
} }
assetsTaskMu.Lock() assetsJobsMu.Lock()
active := assetsTaskState.Running || assetsTaskState.Queued ids := make([]string, 0, len(assetsJobs))
assetsTaskMu.Unlock() for id, job := range assetsJobs {
if job == nil {
continue
}
if !job.State.Queued && !job.State.Running {
continue
}
ids = append(ids, id)
}
assetsJobsMu.Unlock()
if !active { cancelled := false
return false for _, id := range ids {
if requestAssetsJobSkip(id, file) {
cancelled = true
}
} }
// Wichtig: return cancelled
// NICHT den globalen Assets-Task canceln.
// Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren.
_ = requestAssetsTaskSkip(file)
return true
} }
func cancelRegenerateAssetsTaskForFile(file string) bool { func cancelRegenerateAssetsTaskForFile(file string) bool {

View File

@ -4,13 +4,16 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
type CleanupTaskState struct { type CleanupTaskState struct {
ID string `json:"id"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
Running bool `json:"running"` Running bool `json:"running"`
QueuedAt time.Time `json:"queuedAt"` QueuedAt time.Time `json:"queuedAt"`
@ -24,6 +27,7 @@ type CleanupTaskState struct {
} }
type CheckVideosTaskState struct { type CheckVideosTaskState struct {
ID string `json:"id"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
Running bool `json:"running"` Running bool `json:"running"`
QueuedAt time.Time `json:"queuedAt"` QueuedAt time.Time `json:"queuedAt"`
@ -44,12 +48,11 @@ type RegenerateAssetsTaskState struct {
} }
type TasksStatusResponse struct { type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"` GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"`
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"`
CheckVideos CheckVideosTaskState `json:"checkVideos"` CleanupJobs []CleanupTaskState `json:"cleanupJobs"`
Cleanup CleanupTaskState `json:"cleanup"` AnyRunning bool `json:"anyRunning"`
AnyRunning bool `json:"anyRunning"`
} }
var ( var (
@ -60,6 +63,13 @@ var (
regenerateAssetsTaskState RegenerateAssetsTaskState regenerateAssetsTaskState RegenerateAssetsTaskState
) )
var nextTaskID atomic.Uint64
func newTaskID(prefix string) string {
n := nextTaskID.Add(1)
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), n)
}
func cleanupNowTime() *time.Time { func cleanupNowTime() *time.Time {
t := time.Now() t := time.Now()
return &t return &t
@ -247,30 +257,56 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
assets := getGenerateAssetsTaskStatus() assetsJobs := snapshotAssetsJobs()
regenerate := snapshotRegenerateAssetsTaskState()
regenerateJobs := getRegenerateAssetsJobsSnapshot() regenerateJobs := getRegenerateAssetsJobsSnapshot()
checkVideos := snapshotCheckVideosTaskState() checkVideosJobs := snapshotCheckVideosJobs()
cleanup := snapshotCleanupTaskState() cleanupJobs := snapshotCleanupJobs()
anyRegenerateRunning := regenerate.Running anyRunning := false
if !anyRegenerateRunning {
if !anyRunning {
for _, st := range assetsJobs {
if st.Queued || st.Running {
anyRunning = true
break
}
}
}
if !anyRunning {
for _, st := range checkVideosJobs {
if st.Queued || st.Running {
anyRunning = true
break
}
}
}
if !anyRunning {
for _, st := range cleanupJobs {
if st.Queued || st.Running {
anyRunning = true
break
}
}
}
if !anyRunning {
for _, job := range regenerateJobs { for _, job := range regenerateJobs {
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"]))) state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
if state == "queued" || state == "running" { if state == "queued" || state == "running" {
anyRegenerateRunning = true anyRunning = true
break break
} }
} }
} }
resp := TasksStatusResponse{ resp := TasksStatusResponse{
GenerateAssets: assets, GenerateAssetsJobs: assetsJobs,
RegenerateAssets: regenerate,
RegenerateAssetsJobs: regenerateJobs, RegenerateAssetsJobs: regenerateJobs,
CheckVideos: checkVideos, CheckVideosJobs: checkVideosJobs,
Cleanup: cleanup, CleanupJobs: cleanupJobs,
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running, AnyRunning: anyRunning,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -0,0 +1,279 @@
// 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,6 +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'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
@ -1804,6 +1805,25 @@ export default function FinishedDownloads({
return renderDownloadIntegrityBadge((job as any)?.meta) return renderDownloadIntegrityBadge((job as any)?.meta)
}, []) }, [])
const renderNSFWBadge = useCallback((job: RecordJob) => {
return (
<FinishedDownloadRating
metaRaw={(job as any)?.meta}
variant="badge"
reserveSpace
/>
)
}, [])
const renderRatingOverlay = useCallback((job: RecordJob) => {
return (
<FinishedDownloadRating
metaRaw={(job as any)?.meta}
variant="overlay"
/>
)
}, [])
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// memoized derived data // memoized derived data
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -3609,13 +3629,6 @@ export default function FinishedDownloads({
let changed = false let changed = false
const next = { ...prev } const next = { ...prev }
for (const oldFile of regenerateTaskFilesRef.current) {
if (!syncedFiles.has(oldFile) && oldFile in next) {
delete next[oldFile]
changed = true
}
}
for (const item of jobs) { for (const item of jobs) {
const file = String(item?.file ?? '').trim() const file = String(item?.file ?? '').trim()
if (!file) continue if (!file) continue
@ -3713,6 +3726,14 @@ export default function FinishedDownloads({
} }
} }
// WICHTIG: stale cleanup erst NACH dem Einsammeln aller aktuellen Dateien
for (const oldFile of regenerateTaskFilesRef.current) {
if (!syncedFiles.has(oldFile) && oldFile in next) {
delete next[oldFile]
changed = true
}
}
return changed ? next : prev return changed ? next : prev
}) })
@ -4870,6 +4891,8 @@ export default function FinishedDownloads({
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
renderPostworkBadge={renderClickablePostworkBadge} renderPostworkBadge={renderClickablePostworkBadge}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderNSFWBadge={renderNSFWBadge}
renderRatingOverlay={renderRatingOverlay}
/> />
</div> </div>
)} )}
@ -4927,6 +4950,7 @@ export default function FinishedDownloads({
forcePreviewMuted={forcePreviewMuted} forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={renderClickablePostworkBadge} renderPostworkBadge={renderClickablePostworkBadge}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
/> />
)} )}
@ -4978,6 +5002,8 @@ export default function FinishedDownloads({
onRecommendedPageSizeChange={setGalleryPageSize} onRecommendedPageSizeChange={setGalleryPageSize}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderNSFWBadge={renderNSFWBadge}
renderRatingOverlay={renderRatingOverlay}
/> />
)} )}
</div> </div>

View File

@ -24,13 +24,11 @@ import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber' import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import type { FinishedDownloadsTeaserState } from './teaserPlayback' import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import { import {
shouldAnimateTeaser, shouldAnimateTeaser,
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
import { SpeakerWaveIcon } from '@heroicons/react/24/solid' import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
import { import {
buildSpriteFrameStyle, buildSpriteFrameStyle,
@ -43,9 +41,13 @@ import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFla
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import { import {
type FinishedSelectionStore, type FinishedSelectionStore,
useSelectionChecked,
} from './finishedSelectionStore' } from './finishedSelectionStore'
import SpritePreloader from './SpritePreloader' import SpritePreloader from './SpritePreloader'
import FinishedDownloadsDesktopCard, {
SelectionCheckboxOverlay,
} from './FinishedDownloadsDesktopCard'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay'
type SwipeRefs = { type SwipeRefs = {
@ -127,6 +129,8 @@ type Props = {
mobileTeaserAudioUnlocked?: boolean mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void onUnlockMobileTeaserAudio?: () => void
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderNSFWBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
} }
function CardBlurWrapper({ function CardBlurWrapper({
@ -309,441 +313,11 @@ function MobileLoadingStack({
) )
} }
type FinishedDownloadsDesktopCardProps = {
job: RecordJob
selectionStore: FinishedSelectionStore
isDeleting: boolean
isKeeping: boolean
isRemoving: boolean
postworkBadge?: FinishedPostworkSummary
blurPreviews?: boolean
durationSeconds?: number
teaserState: FinishedDownloadsTeaserState
inlinePlay: InlinePlayState
assetNonce?: number
forcePreviewMuted?: boolean
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
lower: (s: string) => string
jobForDetails: (job: RecordJob) => RecordJob
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
onToggleSelected: (job: RecordJob) => void
onSelectOnly: (job: RecordJob) => void
onHoverPreviewKeyChange?: (key: string | null) => void
onOpenPlayer: (job: RecordJob) => void
openPlayer: (job: RecordJob) => void
startInline: (key: string) => void
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
renderIntegrityBadge?: (job: RecordJob) => ReactNode
}
function samePostworkSummaryLite(
a?: FinishedPostworkSummary,
b?: FinishedPostworkSummary
) {
if (a === b) return true
if (!a || !b) return !a && !b
return (
a.file === b.file &&
a.state === b.state &&
a.label === b.label &&
a.ts === b.ts
)
}
function isPreviewActiveForKey(
state: FinishedDownloadsTeaserState,
key: string
) {
return state.activeKey === key || state.hoverKey === key
}
function inlineStateForKey(
inlinePlay: InlinePlayState,
key: string
) {
const active = inlinePlay?.key === key
return {
active,
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
}
}
function SelectionCheckboxOverlay({
selectionStore,
rowKey,
label,
onToggle,
}: {
selectionStore: FinishedSelectionStore
rowKey: string
label: string
onToggle: () => void
}) {
const checked = useSelectionChecked(selectionStore, rowKey)
return (
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={label}
onChange={onToggle}
/>
</div>
)
}
const FinishedDownloadsDesktopCard = memo(
function FinishedDownloadsDesktopCard({
job: j,
selectionStore,
isDeleting,
isKeeping,
isRemoving,
postworkBadge,
blurPreviews,
durationSeconds,
teaserState,
inlinePlay,
assetNonce,
forcePreviewMuted,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
lower,
jobForDetails,
handleDuration,
handleResolution,
onToggleSelected,
onHoverPreviewKeyChange,
onOpenPlayer,
openPlayer,
startInline,
registerTeaserHost,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
deleteVideo,
keepVideo,
renderPostworkBadge,
renderIntegrityBadge,
}: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
inlineActive,
disabled: false,
})
const handleSelectOrOpen = useCallback(() => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}, [selectionStore, onToggleSelected, j])
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const tags = parseFinishedTags(flags?.tags)
const modelCountry = resolveFinishedCountry(flags, j)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const meta = parseJobMeta((j as any)?.meta)
const resObj =
typeof meta?.file?.video?.width === 'number' &&
typeof meta?.file?.video?.height === 'number' &&
meta.file.video.width > 0 &&
meta.file.video.height > 0
? { w: meta.file.video.width, h: meta.file.video.height }
: null
const resLabel = formatResolution(resObj)
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
return (
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className={[
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j)
}
}}
>
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
startInline(k)
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{!inlineActive ? (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
) : null}
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: false,
})}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
</div>
<div
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
</div>
{(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
<div className="relative z-20 shrink-0 flex items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={Boolean(flags?.favorite)}
isLiked={flags?.liked === true}
isWatching={Boolean(flags?.watching)}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
},
(prev, next) => {
const prevKey = prev.keyFor(prev.job)
const nextKey = next.keyFor(next.job)
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
return (
prev.job === next.job &&
prev.isDeleting === next.isDeleting &&
prev.isKeeping === next.isKeeping &&
prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
prev.durationSeconds === next.durationSeconds &&
prev.assetNonce === next.assetNonce &&
prev.forcePreviewMuted === next.forcePreviewMuted &&
prev.blurPreviews === next.blurPreviews &&
prev.teaserState.mode === next.teaserState.mode &&
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
prevPreviewActive === nextPreviewActive &&
prevInline.active === nextInline.active &&
prevInline.nonce === nextInline.nonce &&
prev.modelsByKey === next.modelsByKey &&
prev.activeTagSet === next.activeTagSet &&
prev.renderPostworkBadge === next.renderPostworkBadge
)
}
)
function FinishedDownloadsCardsView({ function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
selectionStore, selectionStore,
onToggleSelected, onToggleSelected,
onSelectOnly,
isLoading, isLoading,
blurPreviews, blurPreviews,
durations, durations,
@ -775,6 +349,7 @@ function FinishedDownloadsCardsView({
registerTeaserHost, registerTeaserHost,
handleDuration, handleDuration,
handleResolution, handleResolution,
resolutionLabelOf,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
@ -796,6 +371,8 @@ function FinishedDownloadsCardsView({
mobileTeaserAudioUnlocked, mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio, onUnlockMobileTeaserAudio,
renderIntegrityBadge, renderIntegrityBadge,
renderNSFWBadge,
renderRatingOverlay,
}: Props) { }: Props) {
const footerTapRef = useRef<Record<string, boolean>>({}) const footerTapRef = useRef<Record<string, boolean>>({})
@ -816,23 +393,6 @@ function FinishedDownloadsCardsView({
return parseJobMeta((j as any)?.meta) return parseJobMeta((j as any)?.meta)
}, []) }, [])
const resolutionObjOf = useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseMeta(j)
const w =
typeof meta?.file?.video?.width === 'number' && Number.isFinite(meta.file.video.width)
? meta.file.video.width
: 0
const h =
typeof meta?.file?.video?.height === 'number' && Number.isFinite(meta.file.video.height)
? meta.file.video.height
: 0
if (w > 0 && h > 0) return { w, h }
return null
}, [parseMeta])
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({}) const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({}) const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({})
@ -993,8 +553,24 @@ function FinishedDownloadsCardsView({
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j) const meta = parseMeta(j)
const resLabel = formatResolution(resObj)
const resObj = (() => {
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
return { w, h }
}
return null
})()
const resLabelRaw = resolutionLabelOf(j)
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
const inlineDomId = `inline-prev-${encodeURIComponent(k)}` const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
@ -1109,33 +685,20 @@ function FinishedDownloadsCardsView({
> >
{/* media */} {/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> <div className="absolute inset-0 overflow-hidden rounded-t-lg">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */} <PreviewRatingOverlay
{!inlineActive ? ( hidden={inlineActive}
<div raised={!inlineActive && scrubberCount > 1 && hoveredThumbKey === k}
className={ >
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' + {renderRatingOverlay ? renderRatingOverlay(j) : null}
'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' + </PreviewRatingOverlay>
(inlineActive ? 'opacity-0' : 'opacity-100')
} <PreviewMetaOverlay
> hidden={inlineActive}
<div dur={dur}
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]" resObj={resObj}
title={[ resLabel={resLabel}
dur, size={size}
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', />
size,
]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
) : null}
<FinishedVideoPreview <FinishedVideoPreview
job={j} job={j}
@ -1293,9 +856,9 @@ function FinishedDownloadsCardsView({
openPlayer(j) openPlayer(j)
}} }}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex min-h-6 items-center justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2 min-w-0"> <div className="flex min-h-6 items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5"> <div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model} {model}
@ -1313,9 +876,10 @@ function FinishedDownloadsCardsView({
/> />
</div> </div>
{(renderIntegrityBadge || (renderPostworkBadge && livePostwork)) ? ( {(renderIntegrityBadge || renderNSFWBadge || (renderPostworkBadge && livePostwork)) ? (
<div className="relative z-20 shrink-0 flex items-center gap-2"> <div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderNSFWBadge ? renderNSFWBadge(j) : null}
{renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null} {renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
</div> </div>
) : null} ) : null}
@ -1573,9 +1137,8 @@ function FinishedDownloadsCardsView({
jobForDetails={jobForDetails} jobForDetails={jobForDetails}
handleDuration={handleDuration} handleDuration={handleDuration}
handleResolution={handleResolution} handleResolution={handleResolution}
handleScrubberClickIndex={handleScrubberClickIndex} resolutionLabelOf={resolutionLabelOf}
onToggleSelected={onToggleSelected} onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly}
onHoverPreviewKeyChange={onHoverPreviewKeyChange} onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer} openPlayer={openPlayer}
@ -1594,6 +1157,8 @@ function FinishedDownloadsCardsView({
keepVideo={keepVideo} keepVideo={keepVideo}
renderPostworkBadge={renderPostworkBadge} renderPostworkBadge={renderPostworkBadge}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderNSFWBadge={renderNSFWBadge}
renderRatingOverlay={renderRatingOverlay}
/> />
) )
})} })}

View File

@ -0,0 +1,477 @@
// frontend\src\components\ui\FinishedDownloadsDesktopCard.tsx
'use client'
import { memo, useCallback, type ReactNode } from 'react'
import Card from './Card'
import Checkbox from './Checkbox'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import FinishedVideoPreview from './FinishedVideoPreview'
import ModelGenderIcon from './ModelGenderIcon'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
import { isHotName, stripHotPrefix } from './hotName'
import {
type FinishedSelectionStore,
useSelectionChecked,
} from './finishedSelectionStore'
import type {
FinishedPostworkSummary,
GalleryModelFlags,
InlinePlayState,
RecordJob,
} from '../../types'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
import { parseJobMeta } from './previewSprite'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay'
export type FinishedDownloadsDesktopCardProps = {
job: RecordJob
selectionStore: FinishedSelectionStore
isDeleting: boolean
isKeeping: boolean
isRemoving: boolean
postworkBadge?: FinishedPostworkSummary
blurPreviews?: boolean
durationSeconds?: number
teaserState: FinishedDownloadsTeaserState
inlinePlay: InlinePlayState
assetNonce?: number
forcePreviewMuted?: boolean
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
lower: (s: string) => string
jobForDetails: (job: RecordJob) => RecordJob
handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string
onToggleSelected: (job: RecordJob) => void
onHoverPreviewKeyChange?: (key: string | null) => void
onOpenPlayer: (job: RecordJob) => void
openPlayer: (job: RecordJob) => void
startInline: (key: string) => void
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderNSFWBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
}
function samePostworkSummaryLite(
a?: FinishedPostworkSummary,
b?: FinishedPostworkSummary
) {
if (a === b) return true
if (!a || !b) return !a && !b
return (
a.file === b.file &&
a.state === b.state &&
a.label === b.label &&
a.ts === b.ts
)
}
function isPreviewActiveForKey(
state: FinishedDownloadsTeaserState,
key: string
) {
return state.activeKey === key || state.hoverKey === key
}
function inlineStateForKey(
inlinePlay: InlinePlayState,
key: string
) {
const active = inlinePlay?.key === key
return {
active,
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
}
}
export function SelectionCheckboxOverlay({
selectionStore,
rowKey,
label,
onToggle,
}: {
selectionStore: FinishedSelectionStore
rowKey: string
label: string
onToggle: () => void
}) {
const checked = useSelectionChecked(selectionStore, rowKey)
return (
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={label}
onChange={onToggle}
/>
</div>
)
}
const FinishedDownloadsDesktopCard = memo(
function FinishedDownloadsDesktopCard({
job: j,
selectionStore,
isDeleting,
isKeeping,
isRemoving,
postworkBadge,
blurPreviews,
durationSeconds,
teaserState,
inlinePlay,
assetNonce,
forcePreviewMuted,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
lower,
jobForDetails,
handleDuration,
handleResolution,
resolutionLabelOf,
onToggleSelected,
onHoverPreviewKeyChange,
onOpenPlayer,
openPlayer,
startInline,
registerTeaserHost,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
deleteVideo,
keepVideo,
renderPostworkBadge,
renderIntegrityBadge,
renderNSFWBadge,
renderRatingOverlay,
}: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
inlineActive,
disabled: false,
})
const handleSelectOrOpen = useCallback(() => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}, [selectionStore, onToggleSelected, j])
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const tags = parseFinishedTags(flags?.tags)
const modelCountry = resolveFinishedCountry(flags, j)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const meta = parseJobMeta((j as any)?.meta)
const resObj = (() => {
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
return { w, h }
}
return null
})()
const resLabelRaw = resolutionLabelOf(j)
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
return (
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className={[
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j)
}
}}
>
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
startInline(k)
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<PreviewRatingOverlay hidden={inlineActive}>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
<PreviewMetaOverlay
hidden={inlineActive}
dur={dur}
resObj={resObj}
resLabel={resLabel}
size={size}
/>
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: false,
})}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
</div>
<div
className="relative min-h-[112px] overflow-visible border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
>
<div className="flex min-h-6 items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-h-6 min-w-0 items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
</div>
{(renderIntegrityBadge || renderNSFWBadge || (renderPostworkBadge && postworkBadge)) ? (
<div className="relative z-20 flex h-6 shrink-0 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderNSFWBadge ? renderNSFWBadge(j) : null}
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div>
) : null}
</div>
<div className="mt-0.5 flex min-w-0 items-start gap-2">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={Boolean(flags?.favorite)}
isLiked={flags?.liked === true}
isWatching={Boolean(flags?.watching)}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
},
(prev, next) => {
const prevKey = prev.keyFor(prev.job)
const nextKey = next.keyFor(next.job)
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
return (
prev.job === next.job &&
prev.isDeleting === next.isDeleting &&
prev.isKeeping === next.isKeeping &&
prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
prev.durationSeconds === next.durationSeconds &&
prev.assetNonce === next.assetNonce &&
prev.forcePreviewMuted === next.forcePreviewMuted &&
prev.blurPreviews === next.blurPreviews &&
prev.teaserState.mode === next.teaserState.mode &&
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
prevPreviewActive === nextPreviewActive &&
prevInline.active === nextInline.active &&
prevInline.nonce === nextInline.nonce &&
prev.modelsByKey === next.modelsByKey &&
prev.activeTagSet === next.activeTagSet &&
prev.renderPostworkBadge === next.renderPostworkBadge
)
}
)
export default FinishedDownloadsDesktopCard

View File

@ -29,6 +29,8 @@ import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags' import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import SpritePreloader from './SpritePreloader' import SpritePreloader from './SpritePreloader'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay'
function normalizeDurationSeconds(value: unknown): number | undefined { function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -89,6 +91,8 @@ type Props = {
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderNSFWBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
contentPaddingClass: string contentPaddingClass: string
footerMinHeightClass: string footerMinHeightClass: string
@ -140,6 +144,8 @@ function FinishedDownloadsGalleryCardInner({
onAddToDownloads, onAddToDownloads,
jobForDetails, jobForDetails,
renderIntegrityBadge, renderIntegrityBadge,
renderNSFWBadge,
renderRatingOverlay,
contentPaddingClass, contentPaddingClass,
footerMinHeightClass, footerMinHeightClass,
titleClass, titleClass,
@ -381,6 +387,19 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
<PreviewRatingOverlay
raised={hasScrubber && hoveredThumbKey === k}
>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
<PreviewMetaOverlay
dur={dur}
resObj={resObj}
resLabel={resLabel}
size={size}
/>
<SpritePreloader <SpritePreloader
src={spriteUrl} src={spriteUrl}
enabled={shouldPreloadSprite} enabled={shouldPreloadSprite}
@ -411,19 +430,6 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
) : null} ) : null}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
</div> </div>
</div> </div>
@ -438,7 +444,7 @@ function FinishedDownloadsGalleryCardInner({
}} }}
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0"> <div className="mt-0.5 flex min-h-6 items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5"> <div className="min-w-0 flex items-center gap-1.5">
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}> <div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
{model} {model}
@ -456,9 +462,10 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? ( {(renderIntegrityBadge || renderNSFWBadge || (showPostworkBadge && renderPostworkBadge)) ? (
<div className="relative z-20 shrink-0 flex items-center gap-2"> <div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderNSFWBadge ? renderNSFWBadge(j) : null}
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null} {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div> </div>
) : null} ) : null}

View File

@ -72,6 +72,8 @@ type Props = {
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
assetNonce?: number assetNonce?: number
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderNSFWBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
} }
function snapTo(value: number, step: number, base = 0) { function snapTo(value: number, step: number, base = 0) {
@ -132,6 +134,8 @@ function FinishedDownloadsGalleryView({
forcePreviewMuted, forcePreviewMuted,
assetNonce, assetNonce,
renderIntegrityBadge, renderIntegrityBadge,
renderNSFWBadge,
renderRatingOverlay,
}: Props) { }: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
@ -317,6 +321,8 @@ function FinishedDownloadsGalleryView({
sublineClass={sublineClass} sublineClass={sublineClass}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderNSFWBadge={renderNSFWBadge}
renderRatingOverlay={renderRatingOverlay}
/> />
</div> </div>
) )

View File

@ -25,6 +25,7 @@ import { parseJobMeta } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon' import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags' import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import PreviewRatingOverlay from './PreviewRatingOverlay'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -89,6 +90,7 @@ type Props = {
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
@ -145,6 +147,7 @@ export default function FinishedDownloadsTableView({
keepVideo, keepVideo,
forcePreviewMuted, forcePreviewMuted,
renderIntegrityBadge, renderIntegrityBadge,
renderRatingOverlay,
}: Props) { }: Props) {
const [sort, setSort] = useState<SortState>(null) const [sort, setSort] = useState<SortState>(null)
@ -326,38 +329,43 @@ export default function FinishedDownloadsTableView({
setScrubActiveIndex(k, undefined) setScrubActiveIndex(k, undefined)
}} }}
> >
<FinishedVideoPreview <div className="relative w-28 h-16">
job={jobForDetails(j)} <FinishedVideoPreview
getFileName={baseName} job={jobForDetails(j)}
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds} getFileName={baseName}
muted={previewMuted} durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
popoverMuted={previewMuted} muted={previewMuted}
onDuration={handleDuration} popoverMuted={previewMuted}
onResolution={handleResolution} onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10" onResolution={handleResolution}
showPopover={false} className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
blur={blurPreviews} showPopover={false}
animated={shouldAnimateTeaser({ blur={blurPreviews}
state: teaserState, animated={shouldAnimateTeaser({
itemKey: k, state: teaserState,
})} itemKey: k,
animatedMode="teaser" })}
animatedTrigger="always" animatedMode="teaser"
assetNonce={assetNonce} animatedTrigger="always"
forceActive={isPreviewActive} assetNonce={assetNonce}
teaserPreloadEnabled={false} forceActive={isPreviewActive}
/> teaserPreloadEnabled={false}
{/* Scrub-Frame Overlay */}
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
<img
src={scrubFrameSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute left-0 top-1 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
draggable={false}
/> />
) : null}
<PreviewRatingOverlay>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
<img
src={scrubFrameSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
draggable={false}
/>
) : null}
</div>
</div> </div>
) )
}, },
@ -414,7 +422,7 @@ export default function FinishedDownloadsTableView({
</div> </div>
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? ( {(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
<div className="relative z-20 shrink-0 flex items-center gap-2"> <div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null} {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div> </div>
@ -422,7 +430,7 @@ export default function FinishedDownloadsTableView({
</div> </div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-h-6 items-center gap-2 min-w-0">
<span className="truncate" title={file}> <span className="truncate" title={file}>
{file || '—'} {file || '—'}
</span> </span>
@ -607,7 +615,8 @@ export default function FinishedDownloadsTableView({
onToggleSelectAllPage, onToggleSelectAllPage,
allSelectedOnPage, allSelectedOnPage,
someSelectedOnPage, someSelectedOnPage,
renderIntegrityBadge renderIntegrityBadge,
renderRatingOverlay,
]) ])
const sortStateToMode = useCallback((s: SortState): DoneSortMode => { const sortStateToMode = useCallback((s: SortState): DoneSortMode => {

View File

@ -118,9 +118,6 @@ export default function FinishedVideoPreview({
inlineNonce = 0, inlineNonce = 0,
inlineControls = false, inlineControls = false,
inlineLoop = true, inlineLoop = true,
assetNonce = 0,
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED,
alwaysLoadStill = false, alwaysLoadStill = false,
@ -564,12 +561,11 @@ export default function FinishedVideoPreview({
setTeaserReady(false) setTeaserReady(false)
setTeaserOk(true) setTeaserOk(true)
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = '' emittedDurationRef.current = ''
emittedResolutionRef.current = '' emittedResolutionRef.current = ''
teaserSoundForcedRef.current = false teaserSoundForcedRef.current = false
setMetaLoaded(false) setMetaLoaded(false)
}, [previewId, assetNonce]) }, [previewId])
useEffect(() => { useEffect(() => {
const onRelease = (ev: any) => { const onRelease = (ev: any) => {
@ -674,18 +670,16 @@ export default function FinishedVideoPreview({
return Math.min(dur - 0.05, Math.max(0.05, t)) return Math.min(dur - 0.05, Math.max(0.05, t))
}, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples]) }, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples])
const v = assetNonce ?? 0
const thumbSrc = useMemo(() => { const thumbSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}` if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}`
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}` return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}`
}, [previewId, thumbTimeSec, v]) }, [previewId, thumbTimeSec])
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
}, [previewId, v]) }, [previewId])
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
@ -804,11 +798,10 @@ export default function FinishedVideoPreview({
setThumbOk(true) setThumbOk(true)
setVideoOk(true) setVideoOk(true)
// ✅ Mount-Guards zurücksetzen
lastMountedRef.current.inline = null lastMountedRef.current.inline = null
lastMountedRef.current.teaser = null lastMountedRef.current.teaser = null
lastMountedRef.current.clips = null lastMountedRef.current.clips = null
}, [previewId, assetNonce]) }, [previewId])
if (!videoSrc) { if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} /> return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
@ -1402,7 +1395,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */} {/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? ( {!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
<video <video
key={`teaser-prewarm-${previewId}-${v}`} key={`teaser-prewarm-${previewId}`}
src={teaserSrc} src={teaserSrc}
className="hidden" className="hidden"
muted muted
@ -1426,7 +1419,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser MP4 */} {/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video <video
key={`teaser-${previewId}-${v}`} key={`teaser-${previewId}`}
ref={(el) => { ref={(el) => {
teaserMp4Ref.current = el teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el) bumpMountTickIfNew('teaser', el)

View File

@ -0,0 +1,38 @@
// frontend\src\components\ui\PreviewMetaOverlay.tsx
'use client'
type PreviewMetaOverlayProps = {
dur: string
resLabel?: string
resObj?: { w: number; h: number } | null
size: string
hidden?: boolean
}
export default function PreviewMetaOverlay({
dur,
resLabel,
resObj,
size,
hidden = false,
}: PreviewMetaOverlayProps) {
if (hidden) return null
return (
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="absolute right-2 bottom-0 flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
)
}
export type { PreviewMetaOverlayProps }

View File

@ -0,0 +1,37 @@
// frontend\src\components\ui\PreviewRatingOverlay.tsx
'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">
{children}
</div>
</div>
)
}

View File

@ -4,6 +4,8 @@
import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react' import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react'
export const PREVIEW_SCRUBBER_HEIGHT_PX = 28
type Props = { type Props = {
imageCount: number imageCount: number
activeIndex?: number activeIndex?: number
@ -146,7 +148,6 @@ export default function PreviewScrubber({
ref={rootRef} ref={rootRef}
className={[ className={[
'relative h-7 w-full select-none touch-none cursor-col-resize', 'relative h-7 w-full select-none touch-none cursor-col-resize',
// standardmäßig versteckt, nur bei Hover/Focus auf dem Parent-Video sichtbar
'opacity-0 transition-opacity duration-150', 'opacity-0 transition-opacity duration-150',
'pointer-events-none', 'pointer-events-none',
'group-hover:opacity-100 group-focus-within:opacity-100', 'group-hover:opacity-100 group-focus-within:opacity-100',

File diff suppressed because it is too large Load Diff

View File

@ -2,35 +2,13 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import Button from './Button' import Button from './Button'
import { subscribeSSE } from '../../lib/sseSingleton'
type TaskState = {
running: boolean
total: number
done: number
generatedThumbs: number
generatedPreviews: number
skipped: number
startedAt?: string
finishedAt?: string
error?: string
currentFile?: string
}
type Progress = { done: number; total: number; currentFile?: string }
type Props = { type Props = {
/** API-Endpunkte (optional, wenn onTrigger verwendet wird) */
startUrl?: string startUrl?: string
stopUrl?: string onTrigger?: () => Promise<any> | any
sseUrl?: string
/** Optional: lokaler Trigger statt API/SSE */
onTrigger?: () => Promise<void> | void
/** UI-Texte */
title?: string title?: string
description?: string description?: string
startLabel?: string startLabel?: string
@ -38,13 +16,7 @@ type Props = {
disabled?: boolean disabled?: boolean
busy?: boolean busy?: boolean
/** Callback-Hooks (nur für API/SSE-Variante relevant) */ onCreated?: (job: any) => void
onFinished?: () => void
onStart?: (ac: AbortController) => void
onQueued?: () => void
onProgress?: (p: Progress) => void
onDone?: () => void
onCancelled?: () => void
onError?: (message: string) => void onError?: (message: string) => void
} }
@ -75,192 +47,41 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
export default function Task({ export default function Task({
startUrl, startUrl,
stopUrl,
sseUrl,
onTrigger, onTrigger,
title = 'Task', title = 'Task',
description = 'Startet eine Hintergrundaufgabe. Fortschritt & Abbrechen oben in der Taskliste.', description = 'Startet eine Hintergrundaufgabe.',
startLabel = 'Start', startLabel = 'Start',
startingLabel = 'Starte…', startingLabel = 'Starte…',
disabled = false, disabled = false,
busy = false, busy = false,
onFinished, onCreated,
onStart,
onQueued,
onProgress,
onDone,
onCancelled,
onError, onError,
}: Props) { }: Props) {
const [state, setState] = useState<TaskState | null>(null)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const [startError, setStartError] = useState<string | null>(null) const [startError, setStartError] = useState<string | null>(null)
// Plumbing (für Abbrechen über TaskList)
const abortRef = useRef<AbortController | null>(null)
const armedRef = useRef(false) // onStart nur 1× pro Lauf feuern
const cancelledRef = useRef(false)
const stopInFlightRef = useRef(false)
const prevRunningRef = useRef(false)
const lastErrorRef = useRef('')
const onProgressRef = useRef<Props['onProgress']>(onProgress)
const onErrorRef = useRef<Props['onError']>(onError)
useEffect(() => {
onProgressRef.current = onProgress
}, [onProgress])
useEffect(() => {
onErrorRef.current = onError
}, [onError])
async function stopInternal() {
if (!stopUrl) return
if (stopInFlightRef.current) return
stopInFlightRef.current = true
try {
await fetch(stopUrl, { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
} finally {
stopInFlightRef.current = false
}
}
function ensureControllerCreated() {
if (abortRef.current) return abortRef.current
const ac = new AbortController()
abortRef.current = ac
const onAbort = () => {
cancelledRef.current = true
void stopInternal()
}
ac.signal.addEventListener('abort', onAbort, { once: true })
return ac
}
function armTaskList(ac: AbortController) {
if (armedRef.current) return
armedRef.current = true
onStart?.(ac)
}
// Detect finish (running -> false)
useEffect(() => {
const prev = prevRunningRef.current
const cur = Boolean(state?.running)
prevRunningRef.current = cur
if (prev && !cur) {
const errText = String(state?.error ?? '').trim()
const errTextNorm = errText.toLowerCase()
// Reset pro Run
abortRef.current = null
armedRef.current = false
if (cancelledRef.current || errTextNorm === 'abgebrochen') {
cancelledRef.current = false
onCancelled?.()
} else if (errText) {
// Fehlerfälle werden über onError schon gemeldet
} else {
onDone?.()
}
onFinished?.()
}
}, [state?.running, state?.error, onFinished, onDone, onCancelled])
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => {
if (!sseUrl) return
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
setState(st)
if (st?.running) {
const ac = ensureControllerCreated()
armTaskList(ac)
onProgressRef.current?.({
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
}
const errText = String(st?.error ?? '').trim()
const errTextNorm = errText.toLowerCase()
// Abbruch ist kein "Fehler"-Event für die UI
if (errText && errTextNorm !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)
}
})
return () => unsub()
}, [sseUrl])
async function start() { async function start() {
if (busy) return if (busy) return
if (state?.running) return
if (starting) return if (starting) return
setStartError(null) setStartError(null)
setStarting(true) setStarting(true)
cancelledRef.current = false
lastErrorRef.current = ''
onQueued?.()
// 1) Lokaler Task-Modus
if (onTrigger) {
try {
await onTrigger()
} catch (e: any) {
const msg = e?.message ?? String(e)
setStartError(msg)
onError?.(msg)
} finally {
setStarting(false)
}
return
}
// 2) API/SSE-Task-Modus
if (!startUrl) {
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
setStartError(msg)
onError?.(msg)
setStarting(false)
return
}
const ac = ensureControllerCreated()
try { try {
const st = await fetchJSON<TaskState & { queued?: boolean }>(startUrl, { method: 'POST' }) let created: any = null
setState(st)
// Nur wenn der Task wirklich schon läuft if (onTrigger) {
if (st?.running) { created = await onTrigger()
armTaskList(ac) } else {
if (!startUrl) {
throw new Error('Task ist nicht konfiguriert (startUrl fehlt).')
}
onProgress?.({ created = await fetchJSON<any>(startUrl, { method: 'POST' })
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
} }
} catch (e: any) {
abortRef.current = null
armedRef.current = false
onCreated?.(created)
} catch (e: any) {
const msg = e?.message ?? String(e) const msg = e?.message ?? String(e)
setStartError(msg) setStartError(msg)
onError?.(msg) onError?.(msg)
@ -283,7 +104,11 @@ export default function Task({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Button variant="primary" onClick={start} disabled={disabled || starting}> <Button
variant="primary"
onClick={start}
disabled={disabled || starting}
>
{starting ? startingLabel : startLabel} {starting ? startingLabel : startLabel}
</Button> </Button>
</div> </div>

View File

@ -5,6 +5,20 @@
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
export const DEFAULT_SPRITE_STEP_SECONDS = 5 export const DEFAULT_SPRITE_STEP_SECONDS = 5
export const DEFAULT_PREVIEW_SPRITE_COLS = 12
export const DEFAULT_PREVIEW_SPRITE_ROWS = 10
export const DEFAULT_PREVIEW_SPRITE_COUNT =
DEFAULT_PREVIEW_SPRITE_COLS * DEFAULT_PREVIEW_SPRITE_ROWS
function fixedPreviewSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
// Legacy-Fall, falls alte Sprites noch 80 Frames hatten
if (count === 80) return [10, 8]
// Neue feste Sprite-Layout-Logik aus dem Backend
return [DEFAULT_PREVIEW_SPRITE_COLS, DEFAULT_PREVIEW_SPRITE_ROWS]
}
export function firstNonEmptyString(...values: unknown[]): string | undefined { export function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const v of values) { for (const v of values) {
@ -130,8 +144,7 @@ export function readPreviewSpriteInfo(
? Math.max(0, Math.floor(countRaw)) ? Math.max(0, Math.floor(countRaw))
: 0 : 0
const [inferredCols, inferredRows] = const [fallbackCols, fallbackRows] = fixedPreviewSpriteGrid(count)
count > 1 ? chooseSpriteGrid(count) : [0, 0]
const explicitCols = Number(colsRaw) const explicitCols = Number(colsRaw)
const explicitRows = Number(rowsRaw) const explicitRows = Number(rowsRaw)
@ -144,8 +157,8 @@ export function readPreviewSpriteInfo(
!(count > 1 && explicitCols === 1 && explicitRows === 1) && !(count > 1 && explicitCols === 1 && explicitRows === 1) &&
(count <= 1 || explicitCols * explicitRows >= count) (count <= 1 || explicitCols * explicitRows >= count)
const cols = explicitGridLooksValid ? Math.floor(explicitCols) : inferredCols const cols = explicitGridLooksValid ? Math.floor(explicitCols) : fallbackCols
const rows = explicitGridLooksValid ? Math.floor(explicitRows) : inferredRows const rows = explicitGridLooksValid ? Math.floor(explicitRows) : fallbackRows
const version = const version =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)