From edf4894790f69b02e6dd3a08df7cdc6040b4f9a0 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:02:08 +0200 Subject: [PATCH] added rating --- backend/analyze.go | 160 +++- backend/assets_generate.go | 14 +- backend/assets_sprite.go | 12 +- .../data/pending-autostart/__global__.json | 108 +-- backend/main.go | 10 + backend/meta.go | 1 + backend/nsfw_detector.go | 69 +- backend/record.go | 65 +- backend/server.go | 4 +- backend/split.go | 10 - backend/sse.go | 60 +- backend/tasks_assets.go | 483 ++++++---- backend/tasks_check_video.go | 290 +++--- backend/tasks_cleanup.go | 342 +++++-- backend/tasks_release_file.go | 29 +- backend/tasks_status.go | 72 +- .../components/ui/FinishedDownloadRating.tsx | 279 ++++++ .../src/components/ui/FinishedDownloads.tsx | 40 +- .../ui/FinishedDownloadsCardsView.tsx | 535 +---------- .../ui/FinishedDownloadsDesktopCard.tsx | 477 ++++++++++ .../ui/FinishedDownloadsGalleryCard.tsx | 39 +- .../ui/FinishedDownloadsGalleryView.tsx | 6 + .../ui/FinishedDownloadsTableView.tsx | 77 +- .../components/ui/FinishedVideoPreview.tsx | 25 +- .../src/components/ui/PreviewMetaOverlay.tsx | 38 + .../components/ui/PreviewRatingOverlay.tsx | 37 + .../src/components/ui/PreviewScrubber.tsx | 5 +- .../src/components/ui/RecorderSettings.tsx | 885 +++++------------- frontend/src/components/ui/Task.tsx | 215 +---- frontend/src/components/ui/previewSprite.ts | 21 +- 30 files changed, 2419 insertions(+), 1989 deletions(-) create mode 100644 frontend/src/components/ui/FinishedDownloadRating.tsx create mode 100644 frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx create mode 100644 frontend/src/components/ui/PreviewMetaOverlay.tsx create mode 100644 frontend/src/components/ui/PreviewRatingOverlay.tsx diff --git a/backend/analyze.go b/backend/analyze.go index f923f41..43c25b4 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -3,14 +3,11 @@ package main import ( - "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "image" "image/draw" - "image/jpeg" "math" "net/http" "os" @@ -43,9 +40,23 @@ type analyzeVideoResp struct { Goal string `json:"goal,omitempty"` Hits []analyzeHit `json:"hits"` Segments []aiSegmentMeta `json:"segments,omitempty"` + Rating *aiRatingMeta `json:"rating,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 { Index int Time float64 @@ -136,23 +147,10 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima 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) { _ = ctx - b64, err := encodeImageJPEGBase64(img) - if err != nil { - return nil, err - } - - results, err := detectNSFWFromBase64(b64) + results, err := detectNSFWFromImage(img) if err != nil { return nil, err } @@ -176,10 +174,10 @@ func nsfwLabelPriority(label string) int { case "anus_exposed": return 800 - case "male_genitalia_exposed": + case "buttocks_exposed": return 700 - case "buttocks_exposed": + case "male_genitalia_exposed": return 600 case @@ -339,7 +337,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() var hits []analyzeHit @@ -365,11 +363,17 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { durationSec, _ := durationSecondsForAnalyze(ctx, outPath) segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + var rating *aiRatingMeta + if req.Goal == "nsfw" { + rating = computeNSFWRating(segments, durationSec) + } + ai := &aiAnalysisMeta{ Goal: req.Goal, Mode: req.Mode, Hits: hits, Segments: segments, + Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } @@ -383,6 +387,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { Goal: req.Goal, Hits: hits, Segments: segments, + Rating: rating, }) } @@ -651,6 +656,121 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { return out } +func nsfwSegmentSeverityWeight(label string) float64 { + switch strings.ToLower(strings.TrimSpace(label)) { + case "female_genitalia_exposed": + return 1.00 + case "anus_exposed": + return 0.95 + case "female_breast_exposed": + return 0.85 + case "male_genitalia_exposed": + return 0.80 + case "buttocks_exposed": + return 0.65 + default: + return 0.50 + } +} + +func 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 { if len(hits) == 0 || duration <= 0 { return []aiSegmentMeta{} diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 6598964..c792fdc 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -811,7 +811,7 @@ func ensureAnalyzeForVideoCtx( goal = "nsfw" } - actx, cancel := context.WithTimeout(ctx, 45*time.Second) + actx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() durationSec, _ := durationSecondsForAnalyze(actx, videoPath) @@ -822,11 +822,17 @@ func ensureAnalyzeForVideoCtx( segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + var rating *aiRatingMeta + if goal == "nsfw" { + rating = computeNSFWRating(segments, durationSec) + } + ai := &aiAnalysisMeta{ Goal: goal, Mode: "sprite", Hits: hits, Segments: segments, + Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } @@ -1416,11 +1422,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + var rating *aiRatingMeta + if goal == "nsfw" { + rating = computeNSFWRating(segments, durationSec) + } + ai := &aiAnalysisMeta{ Goal: goal, Mode: "sprite", Hits: hits, Segments: segments, + Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index 9fd175f..7f1305f 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -14,11 +14,11 @@ import ( ) const ( - previewSpriteCols = 10 - previewSpriteRows = 8 + previewSpriteCols = 20 + previewSpriteRows = 12 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows - previewSpriteCellW = 160 - previewSpriteCellH = 90 + previewSpriteCellW = 320 + previewSpriteCellH = 180 ) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { @@ -30,7 +30,7 @@ func previewSpriteStepSeconds(durationSec float64) float64 { return 5 } - // 80 Zellen = 79 Abstände. + // 120 Zellen = 119 Abstände. // Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg // nicht "hinter" dem letzten gültigen Frame sampelt. usableDur := durationSec - 0.10 @@ -135,7 +135,7 @@ func generatePreviewSpriteJPG( "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", - "-q:v", "4", + "-q:v", "3", tmpPath, ) diff --git a/backend/data/pending-autostart/__global__.json b/backend/data/pending-autostart/__global__.json index 34a60b1..d5da555 100644 --- a/backend/data/pending-autostart/__global__.json +++ b/backend/data/pending-autostart/__global__.json @@ -1,114 +1,90 @@ { "items": [ { - "modelKey": "xdaisyx", - "url": "https://www.myfreecams.com/#xDaisyx", + "modelKey": "jam_cream", + "url": "https://www.myfreecams.com/#Jam_cream", "mode": "probe_retry", - "nextProbeAtMs": 1777292591836, + "nextProbeAtMs": 1777366547688, "currentShow": "offline", "source": "watched" }, { - "modelKey": "wekittycluby", - "url": "https://chaturbate.com/wekittycluby/", + "modelKey": "sharitaklemme", + "url": "https://chaturbate.com/sharitaklemme/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.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", + "imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg", "source": "watched" }, { "modelKey": "carolecronan", "url": "https://chaturbate.com/carolecronan/", "mode": "wait_public", - "currentShow": "away", + "currentShow": "private", "imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg", "source": "watched" }, { - "modelKey": "lora_lifelover", - "url": "https://chaturbate.com/lora_lifelover/", + "modelKey": "dawnaullah", + "url": "https://chaturbate.com/dawnaullah/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/dawnaullah.jpg", "source": "watched" }, { - "modelKey": "phantomlace", - "url": "https://chaturbate.com/phantomlace/", + "modelKey": "sexdrugg666", + "url": "https://chaturbate.com/sexdrugg666/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/sexdrugg666.jpg", "source": "watched" }, { - "modelKey": "aurora_the_ballerina", - "url": "https://chaturbate.com/aurora_the_ballerina/", + "modelKey": "yuri_kai", + "url": "https://chaturbate.com/yuri_kai/", "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg", + "currentShow": "away", + "imageUrl": "https://thumb.live.mmcdn.com/ri/yuri_kai.jpg", "source": "watched" }, { - "modelKey": "anabel054", - "url": "https://chaturbate.com/anabel054/", + "modelKey": "kaliana_ggh", + "url": "https://chaturbate.com/kaliana_ggh/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/kaliana_ggh.jpg", "source": "watched" }, { - "modelKey": "phqqipi", - "url": "https://chaturbate.com/phqqipi/", + "modelKey": "vivian_qwerty", + "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", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/kusssecka.jpg", "source": "watched" }, { - "modelKey": "evewilder", - "url": "https://chaturbate.com/evewilder/", + "modelKey": "rafaelaloghry", + "url": "https://chaturbate.com/rafaelaloghry/", "mode": "probe_retry", - "nextProbeAtMs": 1777292606854, + "nextProbeAtMs": 1777366568700, "currentShow": "unknown", "source": "watched" } diff --git a/backend/main.go b/backend/main.go index 1b97caf..4377994 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1207,6 +1207,16 @@ func uniqueDestPath(dstDir, file string) (string, error) { 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 { return canonicalAssetIDFromName(videoPath) } diff --git a/backend/meta.go b/backend/meta.go index ba62e3e..93900f2 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -115,6 +115,7 @@ type aiAnalysisMeta struct { Mode string `json:"mode,omitempty"` Hits []analyzeHit `json:"hits,omitempty"` Segments []aiSegmentMeta `json:"segments,omitempty"` + Rating *aiRatingMeta `json:"rating,omitempty"` AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } diff --git a/backend/nsfw_detector.go b/backend/nsfw_detector.go index 25b8b56..f272093 100644 --- a/backend/nsfw_detector.go +++ b/backend/nsfw_detector.go @@ -154,34 +154,7 @@ func initNSFWDetector() error { return 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) { +func detectNSFWFromImage(img image.Image) ([]NsfwFrameResult, error) { globalNSFW.mu.Lock() defer globalNSFW.mu.Unlock() @@ -189,11 +162,6 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) { return nil, fmt.Errorf("nsfw detector nicht initialisiert") } - img, err := decodeBase64Image(imageB64) - if err != nil { - return nil, err - } - fillInputTensor(globalNSFW.inputTensor.GetData(), img) if err := globalNSFW.session.Run(); err != nil { @@ -231,6 +199,41 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) { 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) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64)) if err != nil { diff --git a/backend/record.go b/backend/record.go index be2e437..f793a12 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1877,6 +1877,55 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { 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 ---------------- func activePostworkBasenameSet() map[string]struct{} { @@ -2319,10 +2368,11 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { c := *base - // Sprite-Infos aus Dateisystem/meta ergänzen - applyPreviewSpriteTruthToRecordJobMeta(&c) + // vollständiges generated meta.json laden (inkl. analysis.ai.rating) + applyGeneratedMetaToRecordJobMeta(&c) - // WICHTIG: completed-Flags für Meta/Thumb/Teaser/Sprites/Analyze setzen + // danach gezielte Wahrheiten/Fallbacks drüberlegen + applyPreviewSpriteTruthToRecordJobMeta(&c) applyFinishedPhaseTruthToRecordJobMeta(&c) out = append(out, &c) @@ -2435,18 +2485,15 @@ func releaseFileForMutation(file string) { return } - assetsReleased := requestAssetsTaskSkip(file) - // Enrich für genau diese Datei stoppen / aus Queue nehmen cancelDeferredEnrichForFile(file) - // WICHTIG: - // releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen. - // Falls das dort aktuell passiert: rausnehmen. + // releaseFileTasks(file) macht jetzt das gezielte Freigeben/Abbrechen + // für genau diese Datei, inkl. generate-assets Skip pro Datei. resp := releaseFileTasks(file) // Kurz warten, damit Handles der betroffenen Datei schließen - if assetsReleased || resp.ReleasedAny { + if resp.ReleasedAny { time.Sleep(250 * time.Millisecond) } } diff --git a/backend/server.go b/backend/server.go index 40a9a42..ecf5098 100644 --- a/backend/server.go +++ b/backend/server.go @@ -149,9 +149,9 @@ func main() { defer cancel() if _, err := refreshChaturbateSnapshotNow(ctx); err != nil { - fmt.Println("⚠️ [chaturbate] startup snapshot failed:", err) + fmt.Println("⚠️ Chaturbate API failed:", err) } else { - fmt.Println("✅ [chaturbate] startup snapshot loaded") + fmt.Println("✅ Chaturbate API geladen") } }() } diff --git a/backend/split.go b/backend/split.go index a6ecfd0..4f0912e 100644 --- a/backend/split.go +++ b/backend/split.go @@ -355,16 +355,6 @@ func splitTimeoutForSegments(count int) time.Duration { 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 { b, err := json.Marshal(ev) if err != nil { diff --git a/backend/sse.go b/backend/sse.go index 5c3552f..bb7b161 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -76,6 +76,7 @@ type taskStateEvent struct { Type string `json:"type"` // "task_state" GenerateAssetsRunning bool `json:"generateAssetsRunning"` RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"` + CheckVideosRunning bool `json:"checkVideosRunning"` CleanupRunning bool `json:"cleanupRunning"` AnyRunning bool `json:"anyRunning"` 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() { - assets := getGenerateAssetsTaskStatus() - regenerate := snapshotRegenerateAssetsTaskState() - cleanup := snapshotCleanupTaskState() + assetsRunning := anyGenerateAssetsTaskRunning() + regenerateRunning := anyRegenerateAssetsTaskRunning() + checkVideosRunning := anyCheckVideosTaskRunning() + cleanupRunning := anyCleanupTaskRunning() ev := taskStateEvent{ Type: "task_state", - GenerateAssetsRunning: assets.Running, - RegenerateAssetsRunning: regenerate.Running, - CleanupRunning: cleanup.Running, - AnyRunning: assets.Running || regenerate.Running || cleanup.Running, + GenerateAssetsRunning: assetsRunning, + RegenerateAssetsRunning: regenerateRunning, + CheckVideosRunning: checkVideosRunning, + CleanupRunning: cleanupRunning, + AnyRunning: assetsRunning || regenerateRunning || checkVideosRunning || cleanupRunning, TS: time.Now().UnixMilli(), } @@ -524,11 +564,7 @@ func jobsSnapshotJSON() []byte { } func assetsSnapshotJSON() []byte { - assetsTaskMu.Lock() - st := assetsTaskState - assetsTaskMu.Unlock() - - b, _ := json.Marshal(st) + b, _ := json.Marshal(snapshotAssetsJobs()) return b } diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 3f6da5c..ba88410 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -3,6 +3,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -19,6 +20,7 @@ import ( // --------------------------- type AssetsTaskState struct { + ID string `json:"id"` Queued bool `json:"queued"` Running bool `json:"running"` QueuedAt time.Time `json:"queuedAt"` @@ -45,12 +47,24 @@ type assetsPhaseSelection struct { Analyze bool } -var assetsTaskMu sync.Mutex -var assetsTaskState AssetsTaskState -var assetsTaskCancel context.CancelFunc +type generateAssetsRequest struct { + Meta *bool `json:"meta,omitempty"` + 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{} -var assetsTaskSkipFiles = map[string]struct{}{} +type assetsTaskJob 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 { s := getSettings() @@ -63,199 +77,258 @@ func selectedAssetsPhasesFromSettings() assetsPhaseSelection { } } -func assetsTaskFileKey(file string) string { - return strings.ToLower(strings.TrimSpace(filepath.Base(file))) -} +func selectedAssetsPhasesFromRequest(req *generateAssetsRequest) assetsPhaseSelection { + sel := selectedAssetsPhasesFromSettings() -func resetAssetsTaskFileControlLocked() { - assetsTaskFileCancels = map[string]context.CancelFunc{} - assetsTaskSkipFiles = map[string]struct{}{} -} - -func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) { - key := assetsTaskFileKey(file) - if key == "" { - return + if req == nil { + return sel } - assetsTaskMu.Lock() - assetsTaskFileCancels[key] = cancel - assetsTaskMu.Unlock() -} - -func clearAssetsTaskFileControl(file string) { - key := assetsTaskFileKey(file) - if key == "" { - return + if req.Meta != nil { + sel.Meta = *req.Meta + } + if req.Thumb != nil { + sel.Thumb = *req.Thumb + } + if req.Teaser != nil { + sel.Teaser = *req.Teaser + } + if req.Sprites != nil { + sel.Sprites = *req.Sprites + } + if req.Analyze != nil { + sel.Analyze = *req.Analyze } - assetsTaskMu.Lock() - 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 + return sel } func (p assetsPhaseSelection) Any() bool { return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze } -// updateAssetsState mutiert den State atomar und triggert danach SSE notify. -// notifyAssetsChanged() muss außerhalb des Locks passieren. -func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { - assetsTaskMu.Lock() - fn(&assetsTaskState) - st := assetsTaskState - assetsTaskMu.Unlock() +func assetsTaskFileKey(file string) string { + return strings.ToLower(strings.TrimSpace(filepath.Base(file))) +} + +func snapshotAssetsJobs() []AssetsTaskState { + assetsJobsMu.Lock() + 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() publishTaskState() - return st + return st, true } -func snapshotAssetsState() AssetsTaskState { - assetsTaskMu.Lock() - st := assetsTaskState - assetsTaskMu.Unlock() - return st +func removeAssetsJob(jobID string) { + assetsJobsMu.Lock() + delete(assetsJobs, jobID) + assetsJobsMu.Unlock() + + notifyAssetsChanged() + publishTaskState() } -func getGenerateAssetsTaskStatus() AssetsTaskState { - return snapshotAssetsState() +func registerAssetsJobFileCancel(jobID, file string, cancel context.CancelFunc) { + 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) { switch r.Method { case http.MethodGet: - // GET bleibt als Fallback/Debug möglich (UI nutzt SSE) - st := snapshotAssetsState() - writeJSON(w, http.StatusOK, st) + writeJSON(w, http.StatusOK, snapshotAssetsJobs()) return case http.MethodPost: - assetsTaskMu.Lock() - if assetsTaskState.Running || assetsTaskState.Queued { - st := assetsTaskState - assetsTaskMu.Unlock() - writeJSON(w, http.StatusOK, st) - return + var req generateAssetsRequest + if r.Body != nil && r.ContentLength != 0 { + _ = json.NewDecoder(r.Body).Decode(&req) + } + sel := selectedAssetsPhasesFromRequest(&req) + + 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()) - assetsTaskCancel = cancel - assetsTaskState = AssetsTaskState{ - 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() + assetsJobsMu.Lock() + assetsJobs[jobID] = job + assetsJobsMu.Unlock() notifyAssetsChanged() publishTaskState() - go func() { - 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() + go runGenerateMissingAssetsJob(jobID, ctx) - updateAssetsState(func(st *AssetsTaskState) { - st.Queued = false - st.Running = true - st.StartedAt = time.Now() - st.Text = "" - }) - - runGenerateMissingAssets(ctx) - }() - - writeJSON(w, http.StatusOK, st) + writeJSON(w, http.StatusOK, job.State) return case http.MethodDelete: - assetsTaskMu.Lock() - cancel := assetsTaskCancel - running := assetsTaskState.Running - queued := assetsTaskState.Queued - assetsTaskMu.Unlock() + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } - if (!running && !queued) || cancel == nil { + assetsJobsMu.Lock() + job := assetsJobs[id] + assetsJobsMu.Unlock() + + if job == nil || job.Cancel == nil { w.WriteHeader(http.StatusNoContent) return } - // canceln: Worker merkt das beim nächsten ctx.Err() und beendet sauber - cancel() - - // 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) + job.Cancel() + w.WriteHeader(http.StatusNoContent) return default: @@ -397,18 +470,30 @@ func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth { 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) defer func() { - assetsTaskMu.Lock() - assetsTaskCancel = nil - assetsTaskMu.Unlock() + assetsJobsMu.Lock() + job := assetsJobs[jobID] + if job != nil { + job.Cancel = nil + } + assetsJobsMu.Unlock() }() finishWithErr := func(err error) { now := time.Now() - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.Queued = false st.Running = false st.FinishedAt = &now @@ -429,19 +514,42 @@ func runGenerateMissingAssets(ctx context.Context) { } }) - assetsTaskMu.Lock() - resetAssetsTaskFileControlLocked() - assetsTaskMu.Unlock() + assetsJobsMu.Lock() + job := assetsJobs[jobID] + 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() - sel := selectedAssetsPhasesFromSettings() + // Wichtig: + // Die Phasen kommen jetzt bereits von außen (Request-Overrides oder gespeicherte Settings) if !sel.Any() { finishWithErr(nil) 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) == "" { finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) 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.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen st.GeneratedThumbs = 0 @@ -538,16 +646,16 @@ func runGenerateMissingAssets(ctx context.Context) { } fileCtx, fileCancel := context.WithCancel(ctx) - registerAssetsTaskFileCancel(it.name, fileCancel) + registerAssetsJobFileCancel(jobID, it.name, fileCancel) finishFileControl := func() { fileCancel() - clearAssetsTaskFileControl(it.name) + clearAssetsJobFileControl(jobID, it.name) } skipCurrentFile := func() { clearAssetsTaskFinishedPostworkStates(it.name, sel) - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.Done++ st.Skipped++ st.CurrentFile = "" @@ -560,7 +668,7 @@ func runGenerateMissingAssets(ctx context.Context) { handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) { if stepErr == nil { - if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { + if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) { return true, false } return false, false @@ -568,7 +676,7 @@ func runGenerateMissingAssets(ctx context.Context) { if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { // globaler Task wurde beendet - if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) { + if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) { finishFileControl() finishWithErr(context.Canceled) return false, true @@ -579,18 +687,18 @@ func runGenerateMissingAssets(ctx context.Context) { } // 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 false, false } - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name }) - if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { + if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) { skipCurrentFile() continue } @@ -601,7 +709,7 @@ func runGenerateMissingAssets(ctx context.Context) { if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { clearAssetsTaskFinishedPostworkStates(it.name, sel) - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.Done++ st.Skipped++ st.CurrentFile = "" @@ -641,7 +749,7 @@ func runGenerateMissingAssets(ctx context.Context) { 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.Done++ st.CurrentFile = "" @@ -668,7 +776,7 @@ func runGenerateMissingAssets(ctx context.Context) { // META // ---------------- if needMeta { - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "meta" @@ -684,6 +792,7 @@ func runGenerateMissingAssets(ctx context.Context) { skipCurrentFile() continue } + if merr != nil || !okMeta { publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") } else { @@ -695,7 +804,7 @@ func runGenerateMissingAssets(ctx context.Context) { // THUMB // ---------------- if needThumb { - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "thumb" @@ -711,6 +820,7 @@ func runGenerateMissingAssets(ctx context.Context) { skipCurrentFile() continue } + if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).ThumbReady { @@ -727,7 +837,7 @@ func runGenerateMissingAssets(ctx context.Context) { // TEASER // ---------------- if needTeaser { - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "teaser" @@ -743,6 +853,7 @@ func runGenerateMissingAssets(ctx context.Context) { skipCurrentFile() continue } + if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).TeaserReady { @@ -759,7 +870,7 @@ func runGenerateMissingAssets(ctx context.Context) { // SPRITES // ---------------- if needSprites { - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "postwork" st.CurrentPhase = "sprites" @@ -775,6 +886,7 @@ func runGenerateMissingAssets(ctx context.Context) { skipCurrentFile() continue } + if serr != nil { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).SpritesReady { @@ -788,7 +900,7 @@ func runGenerateMissingAssets(ctx context.Context) { // ANALYZE // ---------------- if needAnalyze { - updateAssetsState(func(st *AssetsTaskState) { + updateAssetsJobState(jobID, func(st *AssetsTaskState) { st.CurrentFile = it.name st.CurrentQueue = "enrich" st.CurrentPhase = "analyze" @@ -804,6 +916,7 @@ func runGenerateMissingAssets(ctx context.Context) { skipCurrentFile() continue } + if aerr != nil { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } 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 { st.GeneratedThumbs++ } diff --git a/backend/tasks_check_video.go b/backend/tasks_check_video.go index b8a8088..8af6cba 100644 --- a/backend/tasks_check_video.go +++ b/backend/tasks_check_video.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "image" "image/jpeg" @@ -37,21 +38,6 @@ type tailBlackoutResult struct { 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 { s := strings.TrimSpace(name) if s == "" { @@ -63,39 +49,74 @@ func shortTaskFilename(name string, max int) string { return "…" + s[len(s)-(max-1):] } -func snapshotCheckVideosTaskState() CheckVideosTaskState { - checkVideosTask.mu.Lock() - defer checkVideosTask.mu.Unlock() +func snapshotCheckVideosJobs() []CheckVideosTaskState { + checkVideosJobsMu.Lock() + defer checkVideosJobsMu.Unlock() - // Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben - if !checkVideosTask.queued && - !checkVideosTask.running && - !checkVideosTask.finishedAt.IsZero() && - time.Since(checkVideosTask.finishedAt) > 4*time.Second { - return CheckVideosTaskState{} + now := time.Now() + out := make([]CheckVideosTaskState, 0, len(checkVideosJobs)) + + for id, job := range checkVideosJobs { + if job == nil { + 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{ - Queued: checkVideosTask.queued, - Running: checkVideosTask.running, - QueuedAt: checkVideosTask.queuedAt, - StartedAt: checkVideosTask.startedAt, - Text: checkVideosTask.text, - Error: checkVideosTask.err, - CurrentFile: checkVideosTask.currentFile, - Done: checkVideosTask.done, - Total: checkVideosTask.total, - } - - if !checkVideosTask.finishedAt.IsZero() { - finishedAt := checkVideosTask.finishedAt - out.FinishedAt = &finishedAt - } + 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 } -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 { if v < lo { @@ -181,75 +202,85 @@ func collectFinishedVideosForCheck() ([]string, error) { return files, nil } -func finishCheckVideosTask(err error) { - checkVideosTask.mu.Lock() - checkVideosTask.queued = false - checkVideosTask.running = false - checkVideosTask.cancel = nil - checkVideosTask.finishedAt = time.Now() +func finishCheckVideosTask(jobID string, err error) { + now := time.Now() - errText := strings.TrimSpace(func() string { - if err == nil { - return "" + updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) { + st.Queued = false + 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 := - strings.EqualFold(errText, "abgebrochen") || - 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() + time.AfterFunc(4*time.Second, func() { + removeCheckVideosJob(jobID) + }) } -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() if err != nil { - finishCheckVideosTask(err) + finishCheckVideosTask(jobID, err) return } - checkVideosTask.mu.Lock() - checkVideosTask.queued = false - checkVideosTask.running = true - checkVideosTask.total = len(files) - checkVideosTask.done = 0 - checkVideosTask.startedAt = time.Now() - checkVideosTask.text = "Prüfe Videos…" - checkVideosTask.mu.Unlock() - - publishTaskState() + updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) { + st.Queued = false + st.Running = true + st.Total = len(files) + st.Done = 0 + st.StartedAt = time.Now() + st.Text = "Prüfe Videos…" + }) var firstErr error for i, file := range files { select { case <-ctx.Done(): - finishCheckVideosTask(fmt.Errorf("abgebrochen")) + finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen")) return default: } - checkVideosTask.mu.Lock() - checkVideosTask.currentFile = file - checkVideosTask.text = shortTaskFilename(file, 52) - checkVideosTask.done = i - checkVideosTask.mu.Unlock() + updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) { + st.CurrentFile = file + st.Text = shortTaskFilename(file, 52) + st.Done = i + }) if _, err := runTailBlackoutCheck(ctx, file); err != nil { if firstErr == nil { @@ -257,64 +288,73 @@ func runCheckVideosTask(ctx context.Context) { } } - checkVideosTask.mu.Lock() - checkVideosTask.done = i + 1 - checkVideosTask.mu.Unlock() + updateCheckVideosJobState(jobID, func(st *CheckVideosTaskState) { + st.Done = i + 1 + }) } - finishCheckVideosTask(firstErr) + finishCheckVideosTask(jobID, firstErr) } -func startCheckVideosTask() error { - checkVideosTask.mu.Lock() - defer checkVideosTask.mu.Unlock() - - if checkVideosTask.running || checkVideosTask.queued { - return fmt.Errorf("task läuft bereits") - } - +func startCheckVideosTask() (CheckVideosTaskState, error) { + jobID := newTaskID("check-videos") ctx, cancel := context.WithCancel(context.Background()) - checkVideosTask.queued = true - checkVideosTask.running = false - checkVideosTask.done = 0 - checkVideosTask.total = 0 - checkVideosTask.text = "Wartet…" - checkVideosTask.currentFile = "" - checkVideosTask.err = "" - checkVideosTask.queuedAt = time.Now() - checkVideosTask.startedAt = time.Time{} - checkVideosTask.finishedAt = time.Time{} - checkVideosTask.cancel = cancel + st := CheckVideosTaskState{ + ID: jobID, + Queued: true, + Running: false, + Done: 0, + Total: 0, + Text: "Wartet…", + CurrentFile: "", + Error: "", + QueuedAt: time.Now(), + StartedAt: time.Time{}, + FinishedAt: nil, + } + + checkVideosJobsMu.Lock() + checkVideosJobs[jobID] = &checkVideosTaskJob{ + State: st, + Cancel: cancel, + } + checkVideosJobsMu.Unlock() publishTaskState() - go runCheckVideosTask(ctx) - return nil + go runCheckVideosTask(jobID, ctx) + + return st, nil } -func cancelCheckVideosTask() { - checkVideosTask.mu.Lock() - cancel := checkVideosTask.cancel - checkVideosTask.mu.Unlock() +func cancelCheckVideosTask(jobID string) { + checkVideosJobsMu.Lock() + job := checkVideosJobs[jobID] + checkVideosJobsMu.Unlock() - if cancel != nil { - cancel() + if job != nil && job.Cancel != nil { + job.Cancel() } } func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - if err := startCheckVideosTask(); err != nil { + st, err := startCheckVideosTask() + if err != nil { http.Error(w, err.Error(), http.StatusConflict) return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + writeJSON(w, http.StatusOK, st) return 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) return diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index e6685d1..da5f405 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -5,12 +5,14 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" ) @@ -43,106 +45,224 @@ type cleanupReq struct { BelowMB *int `json:"belowMB,omitempty"` } -func clearCleanupTaskState() { - cleanupTaskMu.Lock() - cleanupTaskState = CleanupTaskState{} - cleanupTaskMu.Unlock() +type cleanupTaskJob struct { + State CleanupTaskState + Cancel context.CancelFunc +} + +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() + 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) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - räumt Orphans (preview/thumbs + generated) auf func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) - return - } + switch r.Method { + case http.MethodPost: + s := getSettings() - s := getSettings() - - doneAbs, err := resolvePathRelativeToApp(s.DoneDir) - 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() - }) + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil || strings.TrimSpace(doneAbs) == "" { + http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) return } - defer releaseExclusiveTask() - setCleanupTaskRunning("Räume auf…") + mb := int(s.AutoDeleteSmallDownloadsBelowMB) - resp := cleanupResp{} - - if mb > 0 { - threshold := int64(mb) * 1024 * 1024 - cleanupSmallFiles(doneAbs, threshold, &resp) + 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 } - gcStats := triggerGeneratedGarbageCollectorSync() - resp.GeneratedOrphansChecked = gcStats.Checked - resp.GeneratedOrphansRemoved = gcStats.Removed - resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes - resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) + jobID := newTaskID("cleanup") + ctx, cancel := context.WithCancel(context.Background()) - resp.DeletedBytes += gcStats.RemovedBytes - resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) - - if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { - notifyDoneChanged() + st := CleanupTaskState{ + ID: jobID, + Queued: true, + Running: false, + QueuedAt: time.Now(), + StartedAt: time.Time{}, + FinishedAt: nil, + Text: "Wartet…", + Error: "", + CurrentFile: "", + Done: 0, + Total: 0, } - setCleanupTaskDone( - cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), - ) + cleanupJobsMu.Lock() + cleanupJobs[jobID] = &cleanupTaskJob{ + State: st, + Cancel: cancel, + } + cleanupJobsMu.Unlock() - time.AfterFunc(7*time.Second, func() { - clearCleanupTaskState() - }) - }(doneAbs, mb) + publishTaskState() - 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 { @@ -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 { low := strings.ToLower(name) 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) - collectCandidates := func(dir string, allowSubdirs bool) { + collectCandidates := func(dir string, allowSubdirs bool) error { ents, err := os.ReadDir(dir) if err != nil { - return + return nil } for _, e := range ents { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + full := filepath.Join(dir, e.Name()) if e.IsDir() { @@ -233,20 +359,35 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { size: fi.Size(), }) } + return nil } - collectCandidates(doneAbs, true) + if err := collectCandidates(doneAbs, true); err != nil { + return err + } resp.ScannedFiles = len(candidates) - setCleanupTaskProgress( - 0, - resp.ScannedFiles, - "", - cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), - ) + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + if st.StartedAt.IsZero() { + 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 { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } if c.size < threshold { base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) id := stripHotPrefix(base) @@ -265,13 +406,22 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { } } - setCleanupTaskProgress( - i+1, - resp.ScannedFiles, - "", - cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), - ) + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + if st.StartedAt.IsZero() { + 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 diff --git a/backend/tasks_release_file.go b/backend/tasks_release_file.go index 0e130e6..f9b070c 100644 --- a/backend/tasks_release_file.go +++ b/backend/tasks_release_file.go @@ -48,20 +48,27 @@ func cancelAssetsTaskForFile(file string) bool { return false } - assetsTaskMu.Lock() - active := assetsTaskState.Running || assetsTaskState.Queued - assetsTaskMu.Unlock() + assetsJobsMu.Lock() + ids := make([]string, 0, len(assetsJobs)) + 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 { - return false + cancelled := false + for _, id := range ids { + if requestAssetsJobSkip(id, file) { + cancelled = true + } } - // Wichtig: - // NICHT den globalen Assets-Task canceln. - // Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren. - _ = requestAssetsTaskSkip(file) - - return true + return cancelled } func cancelRegenerateAssetsTaskForFile(file string) bool { diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 2b03cb1..39c3cc8 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -4,13 +4,16 @@ package main import ( "encoding/json" + "fmt" "net/http" "strings" "sync" + "sync/atomic" "time" ) type CleanupTaskState struct { + ID string `json:"id"` Queued bool `json:"queued"` Running bool `json:"running"` QueuedAt time.Time `json:"queuedAt"` @@ -24,6 +27,7 @@ type CleanupTaskState struct { } type CheckVideosTaskState struct { + ID string `json:"id"` Queued bool `json:"queued"` Running bool `json:"running"` QueuedAt time.Time `json:"queuedAt"` @@ -44,12 +48,11 @@ type RegenerateAssetsTaskState struct { } type TasksStatusResponse struct { - GenerateAssets AssetsTaskState `json:"generateAssets"` - RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` - RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` - CheckVideos CheckVideosTaskState `json:"checkVideos"` - Cleanup CleanupTaskState `json:"cleanup"` - AnyRunning bool `json:"anyRunning"` + GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"` + RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` + CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"` + CleanupJobs []CleanupTaskState `json:"cleanupJobs"` + AnyRunning bool `json:"anyRunning"` } var ( @@ -60,6 +63,13 @@ var ( 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 { t := time.Now() return &t @@ -247,30 +257,56 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { return } - assets := getGenerateAssetsTaskStatus() - regenerate := snapshotRegenerateAssetsTaskState() + assetsJobs := snapshotAssetsJobs() regenerateJobs := getRegenerateAssetsJobsSnapshot() - checkVideos := snapshotCheckVideosTaskState() - cleanup := snapshotCleanupTaskState() + checkVideosJobs := snapshotCheckVideosJobs() + cleanupJobs := snapshotCleanupJobs() - anyRegenerateRunning := regenerate.Running - if !anyRegenerateRunning { + anyRunning := false + + 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 { state := strings.TrimSpace(strings.ToLower(anyToString(job["state"]))) if state == "queued" || state == "running" { - anyRegenerateRunning = true + anyRunning = true break } } } resp := TasksStatusResponse{ - GenerateAssets: assets, - RegenerateAssets: regenerate, + GenerateAssetsJobs: assetsJobs, RegenerateAssetsJobs: regenerateJobs, - CheckVideos: checkVideos, - Cleanup: cleanup, - AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running, + CheckVideosJobs: checkVideosJobs, + CleanupJobs: cleanupJobs, + AnyRunning: anyRunning, } w.Header().Set("Content-Type", "application/json") diff --git a/frontend/src/components/ui/FinishedDownloadRating.tsx b/frontend/src/components/ui/FinishedDownloadRating.tsx new file mode 100644 index 0000000..7512844 --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloadRating.tsx @@ -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 | null { + const meta = parseMetaObject(metaRaw) as any + + const rating = + meta?.analysis?.ai?.rating ?? + meta?.ai?.rating ?? + null + + if (!rating || typeof rating !== 'object' || Array.isArray(rating)) { + return null + } + + return rating as Record +} + +function readNSFWStars(metaRaw: unknown): number | undefined { + const rating = readRatingNode(metaRaw) + + const raw = rating?.stars + const n = Number(raw) + if (!Number.isFinite(n)) return undefined + + return Math.max(1, Math.min(5, Math.floor(n))) +} + +function humanizeKey(key: string): string { + return key + .replace(/\./g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (m) => m.toUpperCase()) +} + +function formatScalar(value: unknown): string | null { + if (value == null) return null + + if (typeof value === 'string') { + const s = value.trim() + return s || null + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null + if (Number.isInteger(value)) return String(value) + return value.toFixed(4).replace(/\.?0+$/, '') + } + + if (typeof value === 'boolean') { + return value ? 'Ja' : 'Nein' + } + + if (Array.isArray(value)) { + const parts = value.map(formatScalar).filter(Boolean) as string[] + return parts.length > 0 ? parts.join(', ') : null + } + + return null +} + +function readRatingEntries(metaRaw: unknown): Array<{ key: string; label: string; value: string }> { + const rating = readRatingNode(metaRaw) + if (!rating) return [] + + const out: Array<{ key: string; label: string; value: string }> = [] + + for (const [key, raw] of Object.entries(rating)) { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + for (const [subKey, subRaw] of Object.entries(raw as Record)) { + if (subRaw && typeof subRaw === 'object') continue + + const formatted = formatScalar(subRaw) + if (!formatted) continue + + out.push({ + key: `${key}.${subKey}`, + label: humanizeKey(`${key} ${subKey}`), + value: formatted, + }) + } + continue + } + + const formatted = formatScalar(raw) + if (!formatted) continue + + out.push({ + key, + label: humanizeKey(key), + value: formatted, + }) + } + + out.sort((a, b) => { + if (a.key === 'stars') return -1 + if (b.key === 'stars') return 1 + return a.label.localeCompare(b.label, 'de') + }) + + return out +} + +function nsfwRatingTone(stars: number) { + switch (stars) { + case 1: + return { star: 'text-yellow-400 opacity-40' } + case 2: + return { star: 'text-yellow-400 opacity-55' } + case 3: + return { star: 'text-yellow-400 opacity-70' } + case 4: + return { star: 'text-yellow-400 opacity-85' } + default: + return { star: 'text-yellow-400 opacity-100' } + } +} + +function RatingStar({ + stars, + className, +}: { + stars: number + className: string +}) { + const tone = nsfwRatingTone(stars) + + return ( + + + + + {stars} + + + ) +} + +function RatingWithPopover({ + stars, + metaRaw, + className, +}: { + stars: number + metaRaw: unknown + className: string +}) { + const entries = readRatingEntries(metaRaw) + + const starNode = ( + + + + ) + + if (entries.length === 0) { + return starNode + } + + return ( + + open && ( +
+
+
+ AI Rating +
+
+ Bewertungswerte aus der Analyse +
+
+ +
+
+ {entries.map((entry) => ( +
+ + {entry.label} + + + {entry.value} + +
+ ))} +
+
+
+ ) + } + > + {starNode} +
+ ) +} + +export default function FinishedDownloadRating({ + metaRaw, + variant = 'overlay', + reserveSpace = false, +}: FinishedDownloadRatingProps) { + const stars = readNSFWStars(metaRaw) + + if (typeof stars !== 'number') { + if (reserveSpace && variant === 'badge') { + return