added rating
This commit is contained in:
parent
14412eab4c
commit
edf4894790
@ -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{}
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
279
frontend/src/components/ui/FinishedDownloadRating.tsx
Normal file
279
frontend/src/components/ui/FinishedDownloadRating.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
477
frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx
Normal file
477
frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx
Normal 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
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
38
frontend/src/components/ui/PreviewMetaOverlay.tsx
Normal file
38
frontend/src/components/ui/PreviewMetaOverlay.tsx
Normal 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 }
|
||||||
37
frontend/src/components/ui/PreviewRatingOverlay.tsx
Normal file
37
frontend/src/components/ui/PreviewRatingOverlay.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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',
|
||||||
@ -179,7 +180,7 @@ export default function PreviewScrubber({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'pointer-events-none absolute bottom-[19px] z-10',
|
'pointer-events-none absolute bottom-[19px] z-10',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user