From 32a74e0be031ed9656e4cd1f033a486bd3cd30c9 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:10:34 +0100 Subject: [PATCH] updated --- backend/assets_generate.go | 522 +++++++++- backend/assets_sprite.go | 10 - backend/postwork.go | 63 +- backend/recorder.go | 162 ++- backend/recorder_settings.json | 4 +- backend/server.go | 2 + backend/sse.go | 79 ++ backend/tasks_assets.go | 89 ++ frontend/src/App.tsx | 228 +++- frontend/src/components/ui/Downloads.tsx | 976 ++++++------------ .../src/components/ui/DownloadsCardRow.tsx | 813 +++++++++++++++ .../src/components/ui/FinishedDownloads.tsx | 193 +++- .../ui/FinishedDownloadsCardsView.tsx | 94 +- .../ui/FinishedDownloadsGalleryView.tsx | 75 +- .../ui/FinishedDownloadsTableView.tsx | 69 +- frontend/src/components/ui/ModelsTab.tsx | 8 +- frontend/src/components/ui/SwipeActionRow.tsx | 242 +++++ frontend/src/components/ui/SwipeCard.tsx | 399 +++---- 18 files changed, 3017 insertions(+), 1011 deletions(-) create mode 100644 frontend/src/components/ui/DownloadsCardRow.tsx create mode 100644 frontend/src/components/ui/SwipeActionRow.tsx diff --git a/backend/assets_generate.go b/backend/assets_generate.go index d839942..295da9b 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -163,6 +163,13 @@ type EnsureAssetsResult struct { MetaOK bool } +type EnsurePrimaryAssetsResult struct { + Skipped bool + ThumbGenerated bool + PreviewGenerated bool + MetaOK bool +} + // Public wrappers (kompatibel zu deinem bisherigen API) func ensureAssetsForVideo(videoPath string) error { @@ -187,6 +194,454 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, return res, err } +func ensurePrimaryAssetsForVideoWithProgressCtx( + ctx context.Context, + videoPath string, + sourceURL string, + onRatio func(r float64), +) (EnsurePrimaryAssetsResult, error) { + var out EnsurePrimaryAssetsResult + var sourceInputInvalid bool + + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return out, nil + } + + fi, statErr := os.Stat(videoPath) + if statErr != nil || fi.IsDir() || fi.Size() <= 0 { + return out, nil + } + + if age := time.Since(fi.ModTime()); age < 10*time.Second { + wait := 10*time.Second - age + if wait > 0 { + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-t.C: + case <-ctx.Done(): + return out, ctx.Err() + } + } + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return out, nil + } + + _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) + if perr != nil { + return out, perr + } + + progress := func(r float64) { + if onRatio == nil { + return + } + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + onRatio(r) + } + + thumbBefore := func() bool { + if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 { + return true + } + return false + }() + + previewBefore := func() bool { + if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { + return true + } + return false + }() + + // Basis-Meta nur für Dauer/Props sicherstellen + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + out.MetaOK = meta.ok + + if thumbBefore && previewBefore { + out.Skipped = true + progress(1) + return out, nil + } + + const ( + thumbsW = 0.25 + previewW = 0.75 + ) + + progress(0) + + // ---------------- + // Thumb + // ---------------- + if thumbBefore { + progress(thumbsW) + } else { + progress(0.05) + + func() { + genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + if err := thumbSem.Acquire(genCtx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + return + } + defer thumbSem.Release() + + progress(0.10) + + img, e1 := extractLastFrameJPG(videoPath) + if e1 != nil || len(img) == 0 { + if meta.durSec > 0 { + t := meta.durSec - 0.25 + if t < 0 { + t = 0 + } + img, e1 = extractFrameAtTimeJPG(videoPath, t) + } + if e1 != nil || len(img) == 0 { + img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75) + } + } + + progress(0.20) + + if e1 == nil && len(img) > 0 { + if err := atomicWriteFile(thumbPath, img); err == nil { + out.ThumbGenerated = true + } else { + fmt.Println("⚠️ thumb write:", err) + } + } + }() + + progress(thumbsW) + } + + // ---------------- + // Preview.mp4 + // ---------------- + const ( + previewClipLenSec = 0.75 + previewMaxClips = 12 + ) + + if previewBefore { + progress(1) + } else { + func() { + genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + + progress(thumbsW + 0.02) + + if err := genSem.Acquire(genCtx); err != nil { + return + } + defer genSem.Release() + + progress(thumbsW + 0.05) + + if err := generateTeaserClipsMP4WithProgress( + genCtx, + videoPath, + previewPath, + previewClipLenSec, + previewMaxClips, + func(r float64) { + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + progress(thumbsW + r*previewW) + }, + ); err != nil { + if isFFmpegInputInvalidError(err) { + sourceInputInvalid = true + fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) + return + } + + fmt.Println("⚠️ preview clips:", err) + return + } + + out.PreviewGenerated = true + }() + } + + _ = sourceInputInvalid + progress(1) + return out, nil +} + +func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return nil + } + + _, _, _, spritePath, metaPath, perr := assetPathsForID(id) + if perr != nil { + return perr + } + + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + + // vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen + var computedPreviewClips []previewClip + var spriteMeta *previewSpriteMeta + + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil { + if len(oldMeta.PreviewClips) > 0 { + computedPreviewClips = oldMeta.PreviewClips + } + if oldMeta.PreviewSprite != nil { + spriteMeta = oldMeta.PreviewSprite + } + } + + // previewClips aus Dauer ableiten, falls noch nicht vorhanden + if len(computedPreviewClips) == 0 && meta.durSec > 0 { + const ( + previewClipLenSec = 0.75 + previewMaxClips = 12 + ) + + opts := TeaserPreviewOptions{ + Segments: previewMaxClips, + SegmentDuration: previewClipLenSec, + } + + starts, segDur, _ := computeTeaserStarts(meta.durSec, opts) + + clips := make([]previewClip, 0, len(starts)) + for _, s := range starts { + clips = append(clips, previewClip{ + StartSeconds: math.Round(s*1000) / 1000, + DurationSeconds: math.Round(segDur*1000) / 1000, + }) + } + computedPreviewClips = clips + } + + // Sprite erzeugen, wenn noch nicht vorhanden + spriteExists := false + if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { + spriteExists = true + } + + if !spriteExists && meta.durSec > 0 { + genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + if err := genSem.Acquire(genCtx); err == nil { + func() { + defer genSem.Release() + + cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout() + stepSec := previewSpriteStepSeconds(meta.durSec) + + if err := generatePreviewSpriteJPG( + genCtx, + videoPath, + spritePath, + cols, + rows, + stepSec, + cellW, + cellH, + ); err != nil { + fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) + return + } + + spriteMeta = &previewSpriteMeta{ + Path: fmt.Sprintf("/api/preview-sprite/%s", id), + Count: count, + Cols: cols, + Rows: rows, + StepSeconds: stepSec, + CellWidth: cellW, + CellHeight: cellH, + } + }() + } + } + + // Falls Sprite existiert, aber spriteMeta noch fehlt + if spriteMeta == nil { + if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && meta.durSec > 0 { + cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout() + stepSec := previewSpriteStepSeconds(meta.durSec) + + spriteMeta = &previewSpriteMeta{ + Path: fmt.Sprintf("/api/preview-sprite/%s", id), + Count: count, + Cols: cols, + Rows: rows, + StepSeconds: stepSec, + CellWidth: cellW, + CellHeight: cellH, + } + } + } + + // meta.json mit previewClips + sprite ergänzen + if meta.durSec > 0 { + _ = writeVideoMetaWithPreviewClipsAndSprite( + metaPath, + fi, + meta.durSec, + meta.vw, + meta.vh, + meta.fps, + meta.sourceURL, + computedPreviewClips, + spriteMeta, + ) + } + + // AI erst NACH Sprite + if !hasAIResultsForOutput(videoPath) { + if spriteMeta != nil { + actx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + durationSec, _ := durationSecondsForAnalyze(actx, videoPath) + hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw") + if aerr != nil { + fmt.Println("⚠️ deferred analyze:", aerr) + return nil + } + + segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + + ai := &aiAnalysisMeta{ + Goal: "nsfw", + Mode: "sprite", + Hits: hits, + Segments: segments, + AnalyzedAtUnix: time.Now().Unix(), + } + + if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil { + fmt.Println("⚠️ writeVideoAIForFile:", werr) + } + } + } + + return nil +} + +func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false + } + + key := "enrich:" + id + + ok := enrichQ.Enqueue(PostWorkTask{ + Key: key, + Added: time.Now(), + Run: func(ctx context.Context) error { + if job != nil { + st := enrichQ.StatusForKey(key) + publishFinishedPostworkStateForJob( + job, + "enrich", + st.State, + "assets", + "Ergänze Sprite / AI / Meta…", + st.Position, + st.Waiting, + st.Running, + st.MaxParallel, + ) + } + + err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL) + + if job != nil { + if err != nil { + publishFinishedPostworkStateForJob( + job, + "enrich", + "error", + "assets", + "Ergänzung fehlgeschlagen", + 0, + 0, + 0, + 0, + ) + } else { + publishFinishedPostworkStateForJob( + job, + "enrich", + "done", + "", + "Sprite / AI / Meta fertig", + 0, + 0, + 0, + 0, + ) + } + } + + return err + }, + }) + + if ok && job != nil { + st := enrichQ.StatusForKey(key) + publishFinishedPostworkStateForJob( + job, + "enrich", + st.State, + "assets", + "Warte auf Sprite / AI / Meta…", + st.Position, + st.Waiting, + st.Running, + st.MaxParallel, + ) + } + + return ok +} + // Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist. func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) { var out EnsureAssetsResult @@ -477,7 +932,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return } - fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath, err) + fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) return } @@ -579,40 +1034,77 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string return out, fmt.Errorf("video datei nicht gefunden") } - // 1) Assets sicherstellen (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json) - assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil) + // 1) Nur schnelle / primäre Assets sicherstellen: + // - preview.jpg + // - preview.mp4 + // - basis-meta (duration / width / height / fps) + primaryRes, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, nil) if err != nil { return out, err } - _ = assetsRes + out.MetaOK = primaryRes.MetaOK id := assetIDFromVideoPath(videoPath) if id == "" { return out, fmt.Errorf("konnte asset id nicht ableiten") } - ps := previewSpriteTruthForID(id) - out.SpriteReady = ps.Exists - out.AssetsReady = ps.Exists - out.MetaOK = true - - // 2) AI-Segmente prüfen - if hasAIResultsForOutput(videoPath) { - out.AnalyzeReady = true - return out, nil + _, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id) + if perr != nil { + return out, perr } + thumbExists := func() bool { + if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return true + } + return false + }() + + previewExists := func() bool { + if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return true + } + return false + }() + + out.AssetsReady = thumbExists && previewExists + goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { goal = "nsfw" } - // 3) AI nur ausführen, wenn Sprite vorhanden ist - if !ps.Exists { + // 2) Für Split brauchen wir zusätzlich Sprite + AI. + // Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen. + // (Im normalen Record-Flow laufen sie separat im Hintergrund.) + if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL); err != nil { + // best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten + fmt.Println("⚠️ prepareVideoForSplit deferred enrich:", err) + } + + spriteExists := func() bool { + if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return true + } + return false + }() + + out.SpriteReady = spriteExists + + // 3) AI-Segmente prüfen + if hasAIResultsForOutput(videoPath) { + out.AnalyzeReady = true return out, nil } + // 4) Falls trotz Deferred-Step noch kein Sprite da ist, können wir AI nicht ausführen + if !spriteExists { + return out, nil + } + + // 5) Letzter Fallback: AI jetzt direkt berechnen durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal) if aerr != nil { diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index f043275..b83c135 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -11,7 +11,6 @@ import ( "strings" ) -/* const ( previewSpriteCols = 10 previewSpriteRows = 8 @@ -19,15 +18,6 @@ const ( previewSpriteCellW = 160 previewSpriteCellH = 90 ) -*/ - -const ( - previewSpriteCols = 6 - previewSpriteRows = 5 - previewSpriteFrameCount = previewSpriteCols * previewSpriteRows - previewSpriteCellW = 120 - previewSpriteCellH = 68 -) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { return previewSpriteCols, previewSpriteRows, previewSpriteFrameCount, previewSpriteCellW, previewSpriteCellH diff --git a/backend/postwork.go b/backend/postwork.go index 162123f..7dd6aa5 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -247,10 +247,71 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus { } // global (oder in deinem app struct halten) -var postWorkQ = NewPostWorkQueue(512, 6) // maxParallelFFmpeg = 6 +var postWorkQ = NewPostWorkQueue(512, 6) // schneller kritischer Pfad +var enrichQ = NewPostWorkQueue(512, 2) // langsame Hintergrundaufgaben // --- Status Refresher (ehemals postwork_refresh.go) --- +func startEnrichStatusRefresher() { + t := time.NewTicker(1 * time.Second) + go func() { + defer t.Stop() + + for range t.C { + jobsMu.Lock() + snapshot := make([]*RecordJob, 0, len(jobs)) + for _, job := range jobs { + if job == nil { + continue + } + snapshot = append(snapshot, job) + } + jobsMu.Unlock() + + for _, job := range snapshot { + if job == nil { + continue + } + + videoPath := strings.TrimSpace(job.Output) + if videoPath == "" { + continue + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + continue + } + + key := "enrich:" + id + st := enrichQ.StatusForKey(key) + + // Nur senden, wenn der Job tatsächlich gerade queued/running ist + if st.State != "queued" && st.State != "running" { + continue + } + + label := "Warte auf Sprite / AI / Meta…" + if st.State == "running" { + label = "Ergänze Sprite / AI / Meta…" + } + + publishFinishedPostworkStateForJob( + job, + "enrich", + st.State, + "assets", + label, + st.Position, + st.Waiting, + st.Running, + st.MaxParallel, + ) + } + } + }() +} + func startPostWorkStatusRefresher() { t := time.NewTicker(1 * time.Second) go func() { diff --git a/backend/recorder.go b/backend/recorder.go index 7ce493f..4dc2262 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -456,7 +456,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { return } - // ✅ harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken + // harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken { fi, serr := os.Stat(out) if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { @@ -495,8 +495,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { s := getSettings() minMB := s.AutoDeleteSmallDownloadsBelowMB - // ✅ Wenn AutoDelete aktiv ist und Datei unter Schwellwert liegt: - // NICHT in die Postwork-Queue aufnehmen, sondern direkt löschen + return. if s.AutoDeleteSmallDownloads && minMB > 0 { threshold := int64(minMB) * 1024 * 1024 @@ -532,6 +530,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { postOut := out postTarget := target postKey := "postwork:" + job.ID + postFile := filepath.Base(out) + postAssetID := assetIDFromVideoPath(out) jobsMu.Lock() job.Phase = "postwork" @@ -555,6 +555,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { setJobProgress(job, "postwork", 0) publishJobUpsert(job) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "running", + Phase: "postwork", + Label: "Nachbearbeitung läuft…", + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) } out := strings.TrimSpace(postOut) @@ -566,18 +579,55 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWorkKey = "" job.PostWork = nil jobsMu.Unlock() - publishJobUpsert(job) + + publishJobRemove(job) notifyDoneChanged() + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "done", + Label: "Primäre Nachbearbeitung fertig", + }) return nil } setPhase := func(phase string, pct int) { setJobProgress(job, phase, pct) st := postWorkQ.StatusForKey(postKey) + jobsMu.Lock() job.PostWork = &st jobsMu.Unlock() publishJobUpsert(job) + + label := "Nachbearbeitung läuft…" + switch strings.ToLower(strings.TrimSpace(phase)) { + case "remuxing": + label = "Remux läuft…" + case "moving": + label = "Verschiebe Datei…" + case "probe": + label = "Analysiere Datei…" + case "assets": + label = "Erstelle Preview…" + case "analyze": + label = "Analysiere Inhalt…" + } + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "running", + Phase: phase, + Label: label, + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) } // 1) Remux @@ -595,7 +645,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { // 2) Move to done setPhase("moving", 10) - // ✅ auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben + // auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben { fi, serr := os.Stat(out) if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { @@ -609,6 +659,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { publishJobRemove(job) notifyDoneChanged() + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "moving", + Label: "Nachbearbeitung fehlgeschlagen", + }) + if shouldLogRecordInfo(req) { if serr != nil { fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")") @@ -660,7 +719,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } } - // 5) Assets with progress + // 5) Nur primäre Assets synchron: preview.jpg + preview.mp4 setPhase("assets", 0) lastPct := -1 @@ -694,52 +753,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { setPhase("assets", pct) } - if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { - fmt.Println("⚠️ ensureAssetsForVideo:", err) + if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { + fmt.Println("⚠️ ensurePrimaryAssetsForVideo:", err) } setPhase("assets", 100) - // 6) AI Analyze -> meta.json.ai - setPhase("analyze", 5) - { - actx, cancel := context.WithTimeout(ctx, 45*time.Second) - defer cancel() + // Finalize primary postwork + finalFile := filepath.Base(out) + finalAssetID := assetIDFromVideoPath(out) - id := assetIDFromVideoPath(out) - if strings.TrimSpace(id) == "" { - fmt.Println("⚠️ postwork analyze: keine asset id ableitbar") - } else { - ps := previewSpriteTruthForID(id) - if !ps.Exists { - fmt.Println("⚠️ postwork analyze: preview-sprite.jpg nicht gefunden") - } else { - durationSec, _ := durationSecondsForAnalyze(actx, out) - hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw") - if aerr != nil { - fmt.Println("⚠️ postwork analyze:", aerr) - } else { - setPhase("analyze", 65) - - segments := buildSegmentsFromAnalyzeHits(hits, durationSec) - - ai := &aiAnalysisMeta{ - Goal: "nsfw", - Mode: "sprite", - Hits: hits, - Segments: segments, - AnalyzedAtUnix: time.Now().Unix(), - } - - if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil { - fmt.Println("⚠️ writeVideoAIForFile:", werr) - } - } - } - } - } - setPhase("analyze", 100) - - // Finalize jobsMu.Lock() job.Status = postTarget job.Phase = "" @@ -750,6 +772,28 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { publishJobRemove(job) notifyDoneChanged() + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: finalFile, + AssetID: finalAssetID, + Queue: "postwork", + State: "done", + Label: "Primäre Nachbearbeitung fertig", + }) + + if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok { + fmt.Println("⚠️ deferred enrichment enqueue failed:", out) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: finalFile, + AssetID: finalAssetID, + Queue: "enrich", + State: "error", + Phase: "assets", + Label: "Späte Nachbearbeitung konnte nicht gestartet werden", + }) + } + return nil }, }) @@ -760,6 +804,19 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = &st jobsMu.Unlock() publishJobUpsert(job) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "queued", + Phase: "postwork", + Label: "Warte auf Nachbearbeitung…", + Position: st.Position, + Waiting: st.Waiting, + Running: st.Running, + MaxParallel: st.MaxParallel, + }) } else { jobsMu.Lock() job.Status = postTarget @@ -771,5 +828,14 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { publishJobRemove(job) notifyDoneChanged() + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "error", + Phase: "postwork", + Label: "Nachbearbeitung konnte nicht eingeplant werden", + }) } } diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 21e23cf..79b8a62 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -8,12 +8,12 @@ "autoStartAddedDownloads": false, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": true, + "autoDeleteSmallDownloads": false, "autoDeleteSmallDownloadsBelowMB": 200, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "hover", "teaserAudio": true, "enableNotifications": true, - "encryptedCookies": "" + "encryptedCookies": "Cq0QdJ72VtRxmtC+RGn1DVKb17COrGwrj+FEfgIwgeIn1hcrzvwFCfowqKgJVuPV1cAq9IFv4PnEN/+PiOIC5pxjO/+GWoSBb55E2OjSvknvWYbagMiTSRDvUaDQWzxvbP45VxXOIOelDneU1v5b8lT+cXR5IIY9tRK5hjYw7Yqam4fCkTjTP2EvgjVOvI7vmWfkQcbohhrs9fL06/5FFEuyUQJrsfESk+lge34rhxlJ3OYOqwF7sHke6NttG8CcX30cam5Hzl+/IgFkk34DYXcg4GZOrcgrRwfmEu4/8WeSBt7reBXglt2EkH4RMr9kdNNkXhMXTA9IKeoego9X22mwBXc0rPLT6fWhHykN90qZWtxOdy4JlRTSUaBswu0lnTLNfH0LrGUBzaU5Y5WFsrkFIa/TEEPWdI1EPH4b5GjH7MGjGBRr2U6Aqshy6PsrfHKrR4gIbhqCaoEaYxJjP9aUljFc3rqcw3UgAv1dp8eXo4lm+HPAdtVtNlD+pBsNZO5zXGkXFOk=" } diff --git a/backend/server.go b/backend/server.go index d95d335..4ee6cbf 100644 --- a/backend/server.go +++ b/backend/server.go @@ -18,7 +18,9 @@ func main() { fixKeepRootFilesIntoModelSubdirs() postWorkQ.StartWorkers(1) + enrichQ.StartWorkers(2) startPostWorkStatusRefresher() + startEnrichStatusRefresher() go startGeneratedGarbageCollector() diff --git a/backend/sse.go b/backend/sse.go index b826dcf..ea2120d 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "net/http" + "path/filepath" "sort" "strings" "sync/atomic" @@ -53,6 +54,84 @@ type ssePublishItem struct { Data []byte } +type finishedPostworkEvent struct { + Type string `json:"type"` // "finished_postwork" + Model string `json:"model"` // model event name, z. B. "maypeach" + File string `json:"file"` + AssetID string `json:"assetId,omitempty"` + Queue string `json:"queue"` // "postwork" | "enrich" + State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing" + Phase string `json:"phase,omitempty"` + Label string `json:"label,omitempty"` + Position int `json:"position,omitempty"` + Waiting int `json:"waiting,omitempty"` + Running int `json:"running,omitempty"` + MaxParallel int `json:"maxParallel,omitempty"` + TS int64 `json:"ts"` +} + +func publishFinishedPostworkStateForJob( + j *RecordJob, + queue string, + state string, + phase string, + label string, + position int, + waiting int, + running int, + maxParallel int, +) { + if j == nil { + return + } + + file := strings.TrimSpace(filepath.Base(j.Output)) + if file == "" { + return + } + + assetID := assetIDFromVideoPath(j.Output) + + publishFinishedPostworkStatusForJob(j, finishedPostworkEvent{ + File: file, + AssetID: assetID, + Queue: queue, + State: state, + Phase: phase, + Label: label, + Position: position, + Waiting: waiting, + Running: running, + MaxParallel: maxParallel, + }) +} + +func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) { + if j == nil { + return + } + if strings.TrimSpace(ev.File) == "" { + return + } + + eventName := sseModelEventNameForJob(j) + if eventName == "" { + return + } + + ev.Type = "finished_postwork" + ev.Model = eventName + ev.TS = time.Now().UnixMilli() + + b, _ := json.Marshal(ev) + + // 1) modellbezogen für Running/Downloads + publishSSE(eventName, b) + + // 2) global für FinishedDownloads + publishSSE("finishedPostwork", b) +} + func isTerminalJobStatusForSSE(status JobStatus) bool { s := strings.ToLower(strings.TrimSpace(string(status))) return s == "stopped" || diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 64d9105..f248ffc 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" @@ -133,6 +134,63 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { } } +func publishAssetsTaskFinishedPostworkState(fileName string, state string, label string) { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return + } + + base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + assetID := stripHotPrefix(base) + + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: fileName, + AssetID: assetID, + Queue: "enrich", + State: state, // "queued" | "running" | "done" | "error" | "missing" + Phase: "assets", + Label: label, + TS: time.Now().UnixMilli(), + } + + b, err := json.Marshal(ev) + if err != nil { + return + } + + // Für FinishedDownloads reicht der globale Kanal. + publishSSE("finishedPostwork", b) +} + +func clearAssetsTaskFinishedPostworkState(fileName string) { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return + } + + base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + assetID := stripHotPrefix(base) + + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: fileName, + AssetID: assetID, + Queue: "enrich", + State: "missing", + Phase: "assets", + Label: "", + TS: time.Now().UnixMilli(), + } + + b, err := json.Marshal(ev) + if err != nil { + return + } + + publishSSE("finishedPostwork", b) +} + func runGenerateMissingAssets(ctx context.Context) { // Worker-Ende: CancelFunc zurücksetzen (pro Run) defer func() { @@ -261,10 +319,18 @@ func runGenerateMissingAssets(ctx context.Context) { st.CurrentFile = it.name }) + publishAssetsTaskFinishedPostworkState( + it.name, + "running", + "Erstelle Vorschau / Thumbnails…", + ) + // ID aus Dateiname base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { + clearAssetsTaskFinishedPostworkState(it.name) + updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 }) @@ -274,6 +340,8 @@ func runGenerateMissingAssets(ctx context.Context) { // Datei-Info (validieren) vfi, verr := os.Stat(it.path) if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { + clearAssetsTaskFinishedPostworkState(it.name) + updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 }) @@ -283,6 +351,12 @@ func runGenerateMissingAssets(ctx context.Context) { // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { + publishAssetsTaskFinishedPostworkState( + it.name, + "error", + "Assets fehlgeschlagen", + ) + updateAssetsState(func(st *AssetsTaskState) { // UI bekommt stabilen Hinweis, aber Task läuft weiter st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" @@ -301,6 +375,11 @@ func runGenerateMissingAssets(ctx context.Context) { // Generate/Ensure (einheitliche Core-Funktion) res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) if e != nil { + publishAssetsTaskFinishedPostworkState( + it.name, + "error", + "Assets fehlgeschlagen", + ) finishWithErr(e) return } @@ -312,6 +391,16 @@ func runGenerateMissingAssets(ctx context.Context) { //fmt.Println("⚠️ tasks generate assets analyze:", aerr) } + if res.Skipped { + clearAssetsTaskFinishedPostworkState(it.name) + } else { + publishAssetsTaskFinishedPostworkState( + it.name, + "done", + "Vorschau / Thumbnails fertig", + ) + } + updateAssetsState(func(st *AssetsTaskState) { if res.Skipped { st.Skipped++ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a63c57..2f4010f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -68,7 +68,7 @@ type RecorderSettingsState = { } type JobEvent = { - type?: 'job_upsert' | 'job_remove' + type?: 'job_upsert' | 'job_remove' | 'finished_postwork' model?: string jobId?: string status?: string @@ -97,6 +97,17 @@ type JobEvent = { running?: number maxParallel?: number } + + // ✅ neu für finished_postwork + file?: string + assetId?: string + queue?: 'postwork' | 'enrich' + state?: 'queued' | 'running' | 'done' | 'error' | 'missing' + label?: string + position?: number + waiting?: number + running?: number + maxParallel?: number } const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { @@ -364,6 +375,11 @@ function modelEventKeyFromJob(job: RecordJob): string { return keyFromFile } +function modelEventKeyFromDoneJob(job: RecordJob): string { + const { modelKey } = modelKeyAndHostFromJob(job) + return String(modelKey || '').trim().toLowerCase() +} + function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } { const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() @@ -1147,6 +1163,56 @@ export default function App() { const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent const modelKey = String(msg?.model ?? '').trim().toLowerCase() + // ✅ finished_postwork direkt an FinishedDownloads weiterreichen + if (msg?.type === 'finished_postwork') { + const file = String(msg?.file ?? '').trim() + if (file) { + window.dispatchEvent( + new CustomEvent('finished-downloads:postwork', { + detail: { + file, + assetId: String(msg?.assetId ?? '').trim() || undefined, + queue: + msg?.queue === 'enrich' + ? 'enrich' + : 'postwork', + state: + msg?.state === 'queued' || + msg?.state === 'running' || + msg?.state === 'done' || + msg?.state === 'error' || + msg?.state === 'missing' + ? msg.state + : 'missing', + phase: String(msg?.phase ?? '').trim() || undefined, + label: String(msg?.label ?? '').trim() || undefined, + position: + typeof msg?.position === 'number' && Number.isFinite(msg.position) + ? msg.position + : undefined, + waiting: + typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) + ? msg.waiting + : undefined, + running: + typeof msg?.running === 'number' && Number.isFinite(msg.running) + ? msg.running + : undefined, + maxParallel: + typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) + ? msg.maxParallel + : undefined, + ts: + typeof msg?.ts === 'number' && Number.isFinite(msg.ts) + ? msg.ts + : Date.now(), + }, + }) + ) + } + return + } + if (msg?.type === 'job_upsert') { if (modelKey) { const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() @@ -1482,6 +1548,20 @@ export default function App() { return Array.from(set) }, [modelsByKey, isChaturbateStoreModel]) + const watchedModelKeysLower = useMemo(() => { + const set = new Set() + + for (const m of Object.values(modelsByKey)) { + if (!m?.watching) continue + + const k = lower(String(m?.modelKey ?? '')) + if (!k) continue + + set.add(k) + } + + return Array.from(set) + }, [modelsByKey]) const selectedTabRef = useRef(selectedTab) @@ -2335,9 +2415,63 @@ export default function App() { // ignore } } + + const onFinishedPostwork = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent + const file = String(msg?.file ?? '').trim() + if (!file) return + + window.dispatchEvent( + new CustomEvent('finished-downloads:postwork', { + detail: { + file, + assetId: String(msg?.assetId ?? '').trim() || undefined, + queue: + msg?.queue === 'enrich' + ? 'enrich' + : 'postwork', + state: + msg?.state === 'queued' || + msg?.state === 'running' || + msg?.state === 'done' || + msg?.state === 'error' || + msg?.state === 'missing' + ? msg.state + : 'missing', + phase: String(msg?.phase ?? '').trim() || undefined, + label: String(msg?.label ?? '').trim() || undefined, + position: + typeof msg?.position === 'number' && Number.isFinite(msg.position) + ? msg.position + : undefined, + waiting: + typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) + ? msg.waiting + : undefined, + running: + typeof msg?.running === 'number' && Number.isFinite(msg.running) + ? msg.running + : undefined, + maxParallel: + typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) + ? msg.maxParallel + : undefined, + ts: + typeof msg?.ts === 'number' && Number.isFinite(msg.ts) + ? msg.ts + : Date.now(), + }, + }) + ) + } catch { + // ignore + } + } es.addEventListener('doneChanged', onDoneChanged as any) es.addEventListener('autostart', onAutostart as any) + es.addEventListener('finishedPostwork', onFinishedPostwork as any) // initial nur Listener für wirklich relevante Jobs / Queue setzen @@ -2349,12 +2483,23 @@ export default function App() { if (key) ensureModelEventListener(key) } - // 2) sichtbare Wartenden-Einträge + // 2) sichtbare Finished-Jobs der aktuellen Seite + for (const j of doneJobs) { + const key = modelEventKeyFromDoneJob(j) + if (key) ensureModelEventListener(key) + } + + // 3) sichtbare Wartenden-Einträge for (const p of pendingWatchedRooms) { const k = String(p?.modelKey ?? '').trim().toLowerCase() if (k) ensureModelEventListener(k) } + // 4) watched Models ebenfalls abonnieren + for (const key of watchedModelKeysLower) { + if (key) ensureModelEventListener(key) + } + const onVis = () => { if (document.hidden) return void loadJobs() @@ -2378,6 +2523,7 @@ export default function App() { if (es) { es.removeEventListener('doneChanged', onDoneChanged as any) es.removeEventListener('autostart', onAutostart as any) + es.removeEventListener('finishedPostwork', onFinishedPostwork as any) const handler = onModelJobEventRef.current if (handler) { @@ -2399,6 +2545,9 @@ export default function App() { loadDoneCount, requestFinishedReload, ensureModelEventListener, + doneJobs, + pendingWatchedRooms, + watchedModelKeysLower, ]) useEffect(() => { @@ -2420,24 +2569,35 @@ export default function App() { if (key) desired.add(key) } - // 3) Sichtbare Wartenden-Einträge + // 3) Sichtbare Finished-Jobs der aktuellen Seite + for (const j of doneJobs) { + const key = modelEventKeyFromDoneJob(j) + if (key) desired.add(key) + } + + // 4) Sichtbare Wartenden-Einträge for (const p of pendingWatchedRooms) { const key = String(p?.modelKey ?? '').trim().toLowerCase() if (key) desired.add(key) } - // 4) fehlende Listener hinzufügen + // 5) watched Models + for (const key of watchedModelKeysLower) { + if (key) desired.add(key) + } + + // 6) fehlende Listener hinzufügen for (const key of desired) { ensureModelEventListener(key) } - // 5) alles entfernen, was nicht mehr in Downloads / Nacharbeiten / Wartend ist + // 7) alles entfernen, was nicht mehr sichtbar/relevant ist for (const key of Array.from(modelEventNamesRef.current)) { if (!desired.has(key)) { removeModelEventListener(key) } } - }, [jobs, pendingWatchedRooms, ensureModelEventListener, removeModelEventListener]) + }, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener]) function isChaturbate(raw: string): boolean { const norm = normalizeHttpUrl(raw) @@ -2535,12 +2695,25 @@ export default function App() { const handleAddToDownloads = useCallback( async (job: RecordJob): Promise => { - const raw = String((job as any)?.sourceUrl ?? '') + const raw = String( + (job as any)?.sourceUrl ?? + (job as any)?.SourceURL ?? + (job as any)?.sourceURL ?? + (job as any)?.url ?? + '' + ).trim() + const url0 = extractFirstUrl(raw) - if (!url0) return false + if (!url0) { + notify.error('Konnte URL nicht hinzufügen', 'Für diesen Eintrag ist keine Source-URL vorhanden.') + return false + } const norm0 = normalizeHttpUrl(url0) - if (!norm0) return false + if (!norm0) { + notify.error('Konnte URL nicht hinzufügen', 'Die Source-URL ist ungültig.') + return false + } const url = canonicalizeProviderUrl(norm0) const ok = await startUrl(url, { silent: true }) @@ -2554,6 +2727,37 @@ export default function App() { [startUrl, notify] ) + const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => { + const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase() + const url = String(pending?.url ?? '').trim() + + // 1) Aus pendingAutoStartByKey entfernen + if (keyLower) { + setPendingAutoStartByKey((prev) => { + const next = { ...(prev || {}) } + delete next[keyLower] + pendingAutoStartByKeyRef.current = next + return next + }) + } + + // 2) Aus sichtbarer Warteliste entfernen + setPendingWatchedRooms((prev) => + prev.filter((x) => { + const xKey = String(x?.modelKey ?? '').trim().toLowerCase() + const xUrl = String(x?.url ?? '').trim() + + // bevorzugt über modelKey entfernen + if (keyLower && xKey === keyLower) return false + + // Fallback über URL + if (!keyLower && url && xUrl === url) return false + + return true + }) + ) + }, []) + type FinishedFileActionKind = 'delete' | 'keep' | 'rename' async function runFinishedFileAction(opts: { @@ -3305,6 +3509,7 @@ export default function App() { onToggleLike={handleToggleLike} onToggleWatch={handleToggleWatch} onAddToDownloads={handleAddToDownloads} + onRemovePending={handleRemovePendingWatchedRoom} blurPreviews={Boolean(recSettings.blurPreviews)} /> ) : null} @@ -3326,6 +3531,7 @@ export default function App() { onToggleWatch={handleToggleWatch} onKeepJob={handleKeepJob} onSplitJob={openSplitModal} + onAddToDownloads={handleAddToDownloads} blurPreviews={Boolean(recSettings.blurPreviews)} teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserAudio={Boolean(recSettings.teaserAudio)} @@ -3339,7 +3545,9 @@ export default function App() { /> ) : null} - {selectedTab === 'models' ? : null} + {selectedTab === 'models' ? ( + + ) : null} {selectedTab === 'categories' ? : null} {selectedTab === 'settings' ? (