diff --git a/backend/analyze.go b/backend/analyze.go index 7ea4985..288765b 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -498,8 +498,8 @@ func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyz Time: t, Label: bestLabel, Score: bestScore, - Start: math.Max(0, t-4), - End: t + 4, + Start: t, + End: t, }) } @@ -545,16 +545,12 @@ func analyzeSpriteCandidatesWithAI( continue } - span := inferredSpanSeconds(ps.StepSeconds, 8) - start := math.Max(0, c.Time-(span/2)) - end := c.Time + (span / 2) - hits = append(hits, analyzeHit{ Time: c.Time, Label: bestLabel, Score: bestScore, - Start: start, - End: end, + Start: c.Time, + End: c.Time, }) } @@ -579,14 +575,14 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { start := h.Start end := h.End - if start <= 0 && end <= 0 { + if start < 0 && end < 0 { start = h.Time end = h.Time } else { - if start <= 0 { + if start < 0 { start = h.Time } - if end <= 0 { + if end < 0 { end = h.Time } } @@ -617,13 +613,11 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { for i := 1; i < len(cp); i++ { n := cp[i] - // Nur direkt aufeinanderfolgende Treffer mit gleichem Label zusammenfassen - const mergeGapSeconds = 1.0 - + // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. + // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. sameLabel := strings.EqualFold(cur.Label, n.Label) - touchesOrNear := n.Start <= cur.End+mergeGapSeconds - if sameLabel && touchesOrNear { + if sameLabel { if n.Start < cur.Start { cur.Start = n.Start } @@ -660,18 +654,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme start := hit.Start end := hit.End - if start <= 0 && end <= 0 { + if start < 0 && end < 0 { start = hit.Time end = hit.Time } else { - if start <= 0 { + if start < 0 { start = hit.Time } - if end <= 0 { + if end < 0 { end = hit.Time } } - if start > end { start, end = end, start } diff --git a/backend/assets/img/cb.jpg b/backend/assets/img/cb.jpg new file mode 100644 index 0000000..23fbc8b Binary files /dev/null and b/backend/assets/img/cb.jpg differ diff --git a/backend/assets/img/mfc.jpg b/backend/assets/img/mfc.jpg new file mode 100644 index 0000000..e792955 Binary files /dev/null and b/backend/assets/img/mfc.jpg differ diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 295da9b..d3c889d 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -3,7 +3,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "math" @@ -164,10 +163,9 @@ type EnsureAssetsResult struct { } type EnsurePrimaryAssetsResult struct { - Skipped bool - ThumbGenerated bool - PreviewGenerated bool - MetaOK bool + Skipped bool + ThumbGenerated bool + MetaOK bool } // Public wrappers (kompatibel zu deinem bisherigen API) @@ -231,7 +229,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return out, nil } - _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) + _, thumbPath, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { return out, perr } @@ -256,27 +254,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( 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 { + if thumbBefore { out.Skipped = true progress(1) return out, nil } - const ( - thumbsW = 0.25 - previewW = 0.75 - ) + const thumbsW = 1.0 progress(0) @@ -330,63 +318,299 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( progress(thumbsW) } - // ---------------- - // Preview.mp4 - // ---------------- + _ = sourceInputInvalid + progress(1) + return out, nil +} + +func ensureMetaForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, _, _, _, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + meta, err := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + if err != nil { + return false, err + } + + return meta.ok, nil +} + +func ensureTeaserForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, _, previewPath, _, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 { + return false, nil + } + + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + const ( previewClipLenSec = 0.75 previewMaxClips = 12 ) - if previewBefore { - progress(1) - } else { - func() { - genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() + genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() - progress(thumbsW + 0.02) + if err := genSem.Acquire(genCtx); err != nil { + return false, err + } + defer genSem.Release() - 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 - }() + if err := generateTeaserClipsMP4WithProgress( + genCtx, + videoPath, + previewPath, + previewClipLenSec, + previewMaxClips, + nil, + ); err != nil { + if isFFmpegInputInvalidError(err) { + return false, nil + } + return false, err } - _ = sourceInputInvalid - progress(1) - return out, nil + var computedPreviewClips []previewClip + if meta.durSec > 0 { + 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 + } + + var spriteMeta *previewSpriteMeta + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { + spriteMeta = oldMeta.Preview.Sprite + } + + if meta.durSec > 0 { + _ = writeVideoMetaWithPreviewClipsAndSprite( + metaPath, + fi, + meta.durSec, + meta.vw, + meta.vh, + meta.fps, + meta.sourceURL, + computedPreviewClips, + spriteMeta, + ) + } + + return true, nil +} + +func ensureSpritesForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, _, _, spritePath, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { + return false, nil + } + + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + if !(meta.durSec > 0) { + return false, nil + } + + genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + if err := genSem.Acquire(genCtx); err != nil { + return false, err + } + 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 { + if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { + return false, nil + } + return false, err + } + + var computedPreviewClips []previewClip + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 { + computedPreviewClips = oldMeta.Preview.Clips + } + + spriteMeta := &previewSpriteMeta{ + Path: fmt.Sprintf("/api/preview-sprite/%s", id), + Count: count, + Cols: cols, + Rows: rows, + StepSeconds: stepSec, + CellWidth: cellW, + CellHeight: cellH, + } + + _ = writeVideoMetaWithPreviewClipsAndSprite( + metaPath, + fi, + meta.durSec, + meta.vw, + meta.vh, + meta.fps, + meta.sourceURL, + computedPreviewClips, + spriteMeta, + ) + + return true, nil +} + +func ensureAnalyzeForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, + goal string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + if hasAIResultsForOutput(videoPath) { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, _, _, spritePath, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + + if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 { + return false, nil + } + + goal = strings.ToLower(strings.TrimSpace(goal)) + if goal == "" { + goal = "nsfw" + } + + actx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + durationSec, _ := durationSecondsForAnalyze(actx, videoPath) + hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal) + if aerr != nil { + return false, nil + } + + segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + + ai := &aiAnalysisMeta{ + Goal: goal, + Mode: "sprite", + Hits: hits, + Segments: segments, + AnalyzedAtUnix: time.Now().Unix(), + } + + if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil { + return false, nil + } + + return true, nil } func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { @@ -405,154 +629,27 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return nil } - _, _, _, spritePath, metaPath, perr := assetPathsForID(id) - if perr != nil { - return perr + if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err) } - 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 - } + if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) } - // 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 + spriteTruth := regeneratePhaseTruthForVideo(videoPath, id) + if !spriteTruth.SpritesReady { + return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) } - // 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 + analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") + if err != nil { + return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) } - 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) - } - } + analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id) + if !analyzeReady && !analyzeTruth.AnalyzeReady { + return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id) } return nil @@ -582,7 +679,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", st.State, "assets", - "Ergänze Sprite / AI / Meta…", + "Wird verarbeitet…", st.Position, st.Waiting, st.Running, @@ -611,7 +708,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", "done", "", - "Sprite / AI / Meta fertig", + "Fertig", 0, 0, 0, @@ -631,7 +728,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", st.State, "assets", - "Warte auf Sprite / AI / Meta…", + "Wartet", st.Position, st.Waiting, st.Running, @@ -727,7 +824,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU // aber NICHT sofort returnen, damit meta.json // (previewClips / previewSprite) trotzdem sauber geschrieben wird. metaHasSprite := false - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil { + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { metaHasSprite = true } @@ -841,11 +938,8 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU }); 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 } @@ -907,7 +1001,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return } - genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) defer cancel() if err := genSem.Acquire(genCtx); err != nil { @@ -932,7 +1026,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return } - fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) return } @@ -945,16 +1038,16 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU // Falls wir in diesem Lauf keine neuen Clips berechnet haben: // alte aus gültigem meta.json übernehmen (best effort) if len(computedPreviewClips) == 0 { - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 { - computedPreviewClips = oldMeta.PreviewClips + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 { + computedPreviewClips = oldMeta.Preview.Clips } } // Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben: // altes aus gültigem meta.json übernehmen if spriteMeta == nil { - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil { - spriteMeta = oldMeta.PreviewSprite + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { + spriteMeta = oldMeta.Preview.Sprite } } @@ -991,19 +1084,7 @@ func hasAIResultsForOutput(outPath string) bool { return false } - b, err := os.ReadFile(metaPath) - if err != nil || len(b) == 0 { - return false - } - - var m map[string]any - dec := json.NewDecoder(strings.NewReader(string(b))) - dec.UseNumber() - if err := dec.Decode(&m); err != nil { - return false - } - - aiMap, ok := m["ai"].(map[string]any) + aiMap, ok := readVideoMetaAI(metaPath) if !ok || aiMap == nil { return false } diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index b83c135..66505dd 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "context" "fmt" "os" @@ -107,36 +108,45 @@ func generatePreviewSpriteJPG( cols, rows, ) - cmd := exec.CommandContext( - ctx, - ffmpegPath, + args := []string{ "-hide_banner", "-y", + } + args = append(args, ffmpegInputTol...) + args = append(args, "-i", videoPath, "-an", "-sn", "-threads", "0", "-vf", vf, "-frames:v", "1", + "-update", "1", "-c:v", "mjpeg", "-q:v", "4", - "-f", "image2", tmpPath, ) - out, err := cmd.CombinedOutput() + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { - msg := strings.TrimSpace(string(out)) - if msg == "" { - msg = "(keine ffmpeg-ausgabe)" + msgErr := strings.TrimSpace(stderr.String()) + if msgErr == "" { + msgErr = "(kein stderr)" } + return fmt.Errorf( - "ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s", + "ffmpeg sprite failed for %q -> %q: %w | ctxErr=%v | args=%q | stderr=%s", videoPath, tmpPath, err, + ctx.Err(), strings.Join(cmd.Args, " "), - msg, + msgErr, ) } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index 1dc52f6..8882d9c 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -300,8 +300,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate img, fetchedAt, ) - - publishJobUpsertsForModelKey(modelKey) } // bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen @@ -330,33 +328,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate "", fetchedAt, ) - - publishJobUpsertsForModelKey(modelKey) - } -} - -func publishJobUpsertsForModelKey(modelKey string) { - modelKey = strings.ToLower(strings.TrimSpace(modelKey)) - if modelKey == "" { - return - } - - jobsMu.Lock() - list := make([]*RecordJob, 0, len(jobs)) - for _, j := range jobs { - if j == nil || j.Hidden { - continue - } - if sseModelEventNameForJob(j) != modelKey { - continue - } - c := *j - list = append(list, &c) - } - jobsMu.Unlock() - - for _, j := range list { - publishJobUpsert(j) } } @@ -736,7 +707,6 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st } toStop := make([]*RecordJob, 0, 4) - changedJobs := make([]*RecordJob, 0, 4) jobsMu.Lock() for _, j := range jobs { @@ -759,20 +729,12 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st j.PreviewStateMsg = "" toStop = append(toStop, j) } - - if !j.Hidden { - changedJobs = append(changedJobs, j) - } } jobsMu.Unlock() for _, j := range toStop { stopPreview(j) } - - for _, j := range changedJobs { - publishJobUpsert(j) - } } func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { diff --git a/backend/disk_guard.go b/backend/disk_guard.go index 5d121ef..78460b7 100644 --- a/backend/disk_guard.go +++ b/backend/disk_guard.go @@ -86,7 +86,6 @@ func stopJobsInternal(list []*RecordJob) { } pl := make([]payload, 0, len(list)) - visibleJobs := make([]*RecordJob, 0, len(list)) jobsMu.Lock() for _, job := range list { @@ -97,17 +96,9 @@ func stopJobsInternal(list []*RecordJob) { job.Progress = 10 pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) job.previewCmd = nil - - if !job.Hidden { - visibleJobs = append(visibleJobs, job) - } } jobsMu.Unlock() - for _, job := range visibleJobs { - publishJobUpsert(job) - } - for _, p := range pl { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() @@ -116,10 +107,6 @@ func stopJobsInternal(list []*RecordJob) { p.cancel() } } - - for _, job := range visibleJobs { - publishJobUpsert(job) - } } func stopAllStoppableJobs() int { diff --git a/backend/live.go b/backend/live.go index 7195c51..ecf891b 100644 --- a/backend/live.go +++ b/backend/live.go @@ -350,13 +350,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h job.PreviewState = "" job.PreviewStateAt = "" job.PreviewStateMsg = "" - hidden := job.Hidden jobsMu.Unlock() - if !hidden { - publishJobUpsert(job) - } - commonIn := []string{"-y"} if strings.TrimSpace(userAgent) != "" { commonIn = append(commonIn, "-user_agent", userAgent) @@ -423,13 +418,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h job.PreviewStateMsg = st } } - hidden := job.Hidden jobsMu.Unlock() - if !hidden { - publishJobUpsert(job) - } - //fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) } diff --git a/backend/main.go b/backend/main.go index 1024bc4..3e3dec8 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,22 +11,18 @@ import ( "errors" "fmt" "io" - "math" "net/http" + "net/url" "os" "os/exec" "path/filepath" "regexp" - "runtime" "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/grafov/m3u8" - gocpu "github.com/shirou/gopsutil/v3/cpu" - godisk "github.com/shirou/gopsutil/v3/disk" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) @@ -213,13 +209,6 @@ var ( jobsMu = sync.Mutex{} ) -var serverStartedAt = time.Now() - -var lastCPUUsageBits uint64 // atomic float64 bits - -func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) } -func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) } - func startPreviewIdleKiller() { t := time.NewTicker(5 * time.Second) go func() { @@ -254,8 +243,8 @@ func startPreviewIdleKiller() { } func init() { - initFFmpegSemaphores() - startAdaptiveSemController(context.Background()) + initFFmpegLimiters() + startCPUUsageSampler(context.Background()) startPreviewIdleKiller() initSSE() @@ -271,7 +260,6 @@ func publishJob(jobID string) bool { j.Hidden = false jobsMu.Unlock() - publishJobUpsert(j) return true } @@ -328,228 +316,6 @@ func detectFFprobePath() string { return "ffprobe" } -// ---------- Dynamic Semaphore (resizeable by load controller) ---------- - -type DynSem struct { - mu sync.Mutex - in int - max int - cap int -} - -func NewDynSem(initial, cap int) *DynSem { - if cap < 1 { - cap = 1 - } - if initial < 1 { - initial = 1 - } - if initial > cap { - initial = cap - } - return &DynSem{max: initial, cap: cap} -} - -func (s *DynSem) Acquire(ctx context.Context) error { - for { - if ctx != nil && ctx.Err() != nil { - return ctx.Err() - } - s.mu.Lock() - if s.in < s.max { - s.in++ - s.mu.Unlock() - return nil - } - s.mu.Unlock() - time.Sleep(25 * time.Millisecond) - } -} - -func (s *DynSem) Release() { - s.mu.Lock() - if s.in > 0 { - s.in-- - } - s.mu.Unlock() -} - -func (s *DynSem) SetMax(n int) { - if n < 1 { - n = 1 - } - if n > s.cap { - n = s.cap - } - s.mu.Lock() - s.max = n - s.mu.Unlock() -} - -func (s *DynSem) Max() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.max -} - -func (s *DynSem) Cap() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.cap -} - -func (s *DynSem) InUse() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.in -} - -var ( - genSem *DynSem - previewSem *DynSem - thumbSem *DynSem - durSem *DynSem -) - -func clamp(n, lo, hi int) int { - if n < lo { - return lo - } - if n > hi { - return hi - } - return n -} - -func envInt(name string) (int, bool) { - v := strings.TrimSpace(os.Getenv(name)) - if v == "" { - return 0, false - } - n, err := strconv.Atoi(v) - if err != nil { - return 0, false - } - return n, true -} - -func initFFmpegSemaphores() { - cpu := runtime.NumCPU() - if cpu <= 0 { - cpu = 2 - } - - // Defaults (heuristisch) - previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ - thumbN := clamp(cpu, 2, 12) // Frames -> darf höher - genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein - durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt - - // ENV Overrides (optional) - if n, ok := envInt("PREVIEW_WORKERS"); ok { - previewN = clamp(n, 1, 32) - } - if n, ok := envInt("THUMB_WORKERS"); ok { - thumbN = clamp(n, 1, 64) - } - if n, ok := envInt("GEN_WORKERS"); ok { - genN = clamp(n, 1, 16) - } - if n, ok := envInt("DUR_WORKERS"); ok { - durN = clamp(n, 1, 64) - } - - // Caps (Obergrenzen) – können via ENV überschrieben werden - previewCap := clamp(cpu, 2, 12) - thumbCap := clamp(cpu*2, 4, 32) - genCap := clamp((cpu+1)/2, 2, 12) - durCap := clamp(cpu*2, 4, 32) - - if n, ok := envInt("PREVIEW_CAP"); ok { - previewCap = clamp(n, 1, 64) - } - if n, ok := envInt("THUMB_CAP"); ok { - thumbCap = clamp(n, 1, 128) - } - if n, ok := envInt("GEN_CAP"); ok { - genCap = clamp(n, 1, 64) - } - if n, ok := envInt("DUR_CAP"); ok { - durCap = clamp(n, 1, 128) - } - - // Initial max (Startwerte) - previewSem = NewDynSem(previewN, previewCap) - thumbSem = NewDynSem(thumbN, thumbCap) - genSem = NewDynSem(genN, genCap) - durSem = NewDynSem(durN, durCap) - - /* - fmt.Printf( - "🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n", - previewSem.Max(), previewSem.Cap(), - thumbSem.Max(), thumbSem.Cap(), - genSem.Max(), genSem.Cap(), - durSem.Max(), durSem.Cap(), - cpu, - ) - - fmt.Printf( - "🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n", - previewN, thumbN, genN, durN, cpu, - ) - */ -} - -func startAdaptiveSemController(ctx context.Context) { - targetHi := 85.0 - targetLo := 65.0 - - if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - targetHi = f - } - } - if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - targetLo = f - } - } - - // Warmup (erste Messung kann 0 sein) - _, _ = gocpu.Percent(200*time.Millisecond, false) - - t := time.NewTicker(2 * time.Second) - go func() { - defer t.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-t.C: - p, err := gocpu.Percent(0, false) - if err != nil || len(p) == 0 { - continue - } - usage := p[0] - setLastCPUUsage(usage) - - // Preview ist am teuersten → konservativ - if usage > targetHi { - previewSem.SetMax(previewSem.Max() - 1) - genSem.SetMax(genSem.Max() - 1) - thumbSem.SetMax(thumbSem.Max() - 1) - } else if usage < targetLo { - previewSem.SetMax(previewSem.Max() + 1) - genSem.SetMax(genSem.Max() + 1) - thumbSem.SetMax(thumbSem.Max() + 1) - } - } - } - }() -} - type durEntry struct { size int64 mod time.Time @@ -565,185 +331,6 @@ var startedAtFromFilenameRe = regexp.MustCompile( `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, ) -func buildPerfSnapshot() map[string]any { - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - - s := getSettings() - recordDir, _ := resolvePathRelativeToApp(s.RecordDir) - - var diskFreeBytes uint64 - var diskTotalBytes uint64 - var diskUsedPercent float64 - diskPath := recordDir - - if recordDir != "" { - if u, err := godisk.Usage(recordDir); err == nil && u != nil { - diskFreeBytes = u.Free - diskTotalBytes = u.Total - diskUsedPercent = u.UsedPercent - } - } - - // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) - pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() - - resp := map[string]any{ - "ts": time.Now().UTC().Format(time.RFC3339Nano), - "serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx) - "uptimeSec": time.Since(serverStartedAt).Seconds(), - "cpuPercent": func() float64 { - v := getLastCPUUsage() - if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { - return 0 - } - return v - }(), - "diskPath": diskPath, - "diskFreeBytes": diskFreeBytes, - "diskTotalBytes": diskTotalBytes, - "diskUsedPercent": diskUsedPercent, - "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, - - // ✅ statt LowDiskPauseBelowGB aus Settings - "diskPauseBelowGB": pauseGB, - "diskResumeAboveGB": resumeGB, - - // ✅ optional, aber sehr hilfreich (Debug/UI) - "diskInFlightBytes": inFlight, - "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), - "diskPauseNeedBytes": pauseNeed, - "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), - "diskResumeNeedBytes": resumeNeed, - "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), - - "goroutines": runtime.NumGoroutine(), - "mem": map[string]any{ - "alloc": ms.Alloc, - "heapAlloc": ms.HeapAlloc, - "heapInuse": ms.HeapInuse, - "sys": ms.Sys, - "numGC": ms.NumGC, - }, - } - - sem := map[string]any{} - if genSem != nil { - sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()} - } - if previewSem != nil { - sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()} - } - if thumbSem != nil { - sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()} - } - if durSem != nil { - sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()} - } - if len(sem) > 0 { - resp["sem"] = sem - } - - return resp -} - -func pingHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) -} - -func perfHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) - return - } - - resp := buildPerfSnapshot() - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(resp) -} - -func perfStreamHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) - return - } - - fl, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) - return - } - - // Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000 - ms := 5000 - if q := r.URL.Query().Get("ms"); q != "" { - if v, err := strconv.Atoi(q); err == nil { - // clamp: 1000..30000 - if v < 1000 { - v = 1000 - } - if v > 30000 { - v = 30000 - } - ms = v - } - } - - w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Connection", "keep-alive") - // hilfreich hinter nginx/proxies: - w.Header().Set("X-Accel-Buffering", "no") - - ctx := r.Context() - - // sofort erstes Event schicken - send := func() error { - payload := buildPerfSnapshot() - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(payload); err != nil { - return err - } - - // event: perf - _, _ = io.WriteString(w, "event: perf\n") - _, _ = io.WriteString(w, "data: ") - _, _ = w.Write(buf.Bytes()) - _, _ = io.WriteString(w, "\n") - fl.Flush() - return nil - } - - // initial - _ = send() - - t := time.NewTicker(time.Duration(ms) * time.Millisecond) - hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts - defer t.Stop() - defer hb.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-t.C: - _ = send() - case <-hb.C: - // SSE Kommentar als Heartbeat - _, _ = io.WriteString(w, ": keep-alive\n\n") - fl.Flush() - } - } -} - func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { // returns: (shouldDelete, sizeBytes, thresholdBytes) @@ -796,12 +383,7 @@ func setJobPhase(job *RecordJob, phase string, progress int) { jobsMu.Lock() job.Phase = phase job.Progress = progress - hidden := job.Hidden jobsMu.Unlock() - - if !hidden { - publishJobUpsert(job) - } } func durationSecondsCached(ctx context.Context, path string) (float64, error) { @@ -923,10 +505,46 @@ func detectFFmpegPath() string { return "ffmpeg" } -func removeGeneratedForID(id string) { - // ✅ canonical id: wie beim Erzeugen der generated Ordner +func isAssetIDInUse(id string) bool { id = strings.TrimSpace(id) if id == "" { + return false + } + + jobsMu.Lock() + defer jobsMu.Unlock() + + for _, j := range jobs { + if j == nil { + continue + } + + aid := strings.TrimSpace(assetIDForJob(j)) + if aid == "" || !strings.EqualFold(aid, id) { + continue + } + + // Alles was noch nicht terminal ist, gilt als "in Benutzung" + if !isTerminalJobStatus(j.Status) { + return true + } + + // Auch Postwork/Queue lieber schützen + if isPostworkJob(j) { + return true + } + } + + return false +} + +func removeGeneratedForID(id string) { + id = strings.TrimSpace(id) + if id == "" { + return + } + + if isAssetIDInUse(id) { return } @@ -948,11 +566,6 @@ func removeGeneratedForID(id string) { _ = os.RemoveAll(filepath.Join(root, id)) } - // (optional aber sinnvoll) 1b) Legacy: generated// (falls noch alte Assets existieren) - if root, _ := generatedRoot(); strings.TrimSpace(root) != "" { - _ = os.RemoveAll(filepath.Join(root, id)) - } - // 2) Temp Preview Segmente (HLS) wegräumen // (%TEMP%/rec_preview/) _ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id)) @@ -1695,8 +1308,6 @@ func renameJobsOutputBasename(oldFile, newFile string) { return } - changedJobs := make([]*RecordJob, 0, 4) - jobsMu.Lock() for _, j := range jobs { if j == nil { @@ -1708,21 +1319,14 @@ func renameJobsOutputBasename(oldFile, newFile string) { } if filepath.Base(out) == oldFile { j.Output = filepath.Join(filepath.Dir(out), newFile) - if !j.Hidden { - changedJobs = append(changedJobs, j) - } } } jobsMu.Unlock() - - for _, j := range changedJobs { - publishJobUpsert(j) - } } // nimmt jetzt *HTTPClient entgegen func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) { - if hlsSource == "" { + if strings.TrimSpace(hlsSource) == "" { return nil, errors.New("HLS-URL leer") } @@ -1737,51 +1341,122 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource) + } + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) - if err != nil || listType != m3u8.MASTER { + if err != nil { + return nil, fmt.Errorf("master playlist decode failed: %w", err) + } + if listType != m3u8.MASTER { return nil, errors.New("keine gültige Master-Playlist") } - master := playlist.(*m3u8.MasterPlaylist) - var bestURI string + master, ok := playlist.(*m3u8.MasterPlaylist) + if !ok || master == nil { + return nil, errors.New("master playlist parse fehlgeschlagen") + } + + baseURL, err := url.Parse(hlsSource) + if err != nil { + return nil, fmt.Errorf("master url parse failed: %w", err) + } + + var bestVariant *m3u8.Variant var bestWidth int var bestFramerate int - for _, variant := range master.Variants { - if variant == nil || variant.Resolution == "" { - continue + parseFramerate := func(v *m3u8.Variant) int { + if v == nil { + return 0 } - parts := strings.Split(variant.Resolution, "x") + // 1) Falls BANDWIDTH/FRAME-RATE Infos im Namen kodiert sind + name := strings.ToUpper(strings.TrimSpace(v.Name)) + switch { + case strings.Contains(name, "FPS:60"), strings.Contains(name, "FPS=60"), strings.Contains(name, "60FPS"): + return 60 + case strings.Contains(name, "FPS:50"), strings.Contains(name, "FPS=50"), strings.Contains(name, "50FPS"): + return 50 + case strings.Contains(name, "FPS:30"), strings.Contains(name, "FPS=30"), strings.Contains(name, "30FPS"): + return 30 + } + + // 2) Fallback Standard + return 30 + } + + parseWidth := func(v *m3u8.Variant) int { + if v == nil { + return 0 + } + + res := strings.TrimSpace(v.Resolution) + if res == "" { + return 0 + } + + parts := strings.Split(res, "x") if len(parts) != 2 { - continue + return 0 } - width, err := strconv.Atoi(parts[0]) + w, err := strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil { + return 0 + } + return w + } + + for _, variant := range master.Variants { + if variant == nil || strings.TrimSpace(variant.URI) == "" { continue } - fr := 30 - if strings.Contains(variant.Name, "FPS:60.0") { - fr = 60 - } + width := parseWidth(variant) + fr := parseFramerate(variant) - if width > bestWidth || (width == bestWidth && fr > bestFramerate) { + // auch Varianten ohne Resolution zulassen, aber niedriger priorisieren + if bestVariant == nil || + width > bestWidth || + (width == bestWidth && fr > bestFramerate) { + bestVariant = variant bestWidth = width bestFramerate = fr - bestURI = variant.URI } } - if bestURI == "" { - return nil, errors.New("keine gültige Auflösung gefunden") + if bestVariant == nil { + return nil, errors.New("keine gültige Variante gefunden") + } + + variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI)) + if err != nil { + return nil, fmt.Errorf("variant uri parse failed: %w", err) + } + + playlistURL := baseURL.ResolveReference(variantRef).String() + + playlistParsed, err := url.Parse(playlistURL) + if err != nil { + return nil, fmt.Errorf("playlist url parse failed: %w", err) + } + + rootParsed := *playlistParsed + rootParsed.RawQuery = "" + rootParsed.Fragment = "" + + lastSlash := strings.LastIndex(rootParsed.Path, "/") + if lastSlash >= 0 { + rootParsed.Path = rootParsed.Path[:lastSlash+1] + } else { + rootParsed.Path = "/" } - root := hlsSource[:strings.LastIndex(hlsSource, "/")+1] return &Playlist{ - PlaylistURL: root + bestURI, - RootURL: root, + PlaylistURL: playlistURL, + RootURL: rootParsed.String(), Resolution: bestWidth, Framerate: bestFramerate, }, nil diff --git a/backend/meta.go b/backend/meta.go index c89e657..f270c08 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -1,4 +1,5 @@ -// backend/meta.go +// backend\meta.go + package main import ( @@ -25,28 +26,78 @@ type previewSpriteMeta struct { Count int `json:"count"` // Anzahl Frames im Sprite Cols int `json:"cols"` // Spalten im Tile Rows int `json:"rows"` // Zeilen im Tile - StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames (z.B. 5) + StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames CellWidth int `json:"cellWidth,omitempty"` CellHeight int `json:"cellHeight,omitempty"` } +type videoFileMeta struct { + Size int64 `json:"size"` + ModUnix int64 `json:"modUnix"` + SourceURL string `json:"sourceUrl,omitempty"` +} + +type videoStreamMeta struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + FPS float64 `json:"fps,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type videoMediaMeta struct { + DurationSeconds float64 `json:"durationSeconds"` + Video videoStreamMeta `json:"video"` +} + +type previewThumbMeta struct { + Path string `json:"path,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +type previewMeta struct { + Clips []previewClip `json:"clips,omitempty"` + Thumb *previewThumbMeta `json:"thumb,omitempty"` + Sprite *previewSpriteMeta `json:"sprite,omitempty"` +} + +type analysisMeta struct { + AI *aiAnalysisMeta `json:"ai,omitempty"` +} + +type postworkHistoryEntry struct { + Queue string `json:"queue"` + Phase string `json:"phase"` + State string `json:"state"` + 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"` +} + +type postworkCompletedMeta struct { + Meta bool `json:"meta,omitempty"` + Teaser bool `json:"teaser,omitempty"` + Thumb bool `json:"thumb,omitempty"` + Sprites bool `json:"sprites,omitempty"` + Analyze bool `json:"analyze,omitempty"` +} + +type postworkMeta struct { + Completed *postworkCompletedMeta `json:"completed,omitempty"` + History []postworkHistoryEntry `json:"history,omitempty"` +} + type videoMeta struct { - Version int `json:"version"` - DurationSeconds float64 `json:"durationSeconds"` - FileSize int64 `json:"fileSize"` - FileModUnix int64 `json:"fileModUnix"` - - VideoWidth int `json:"videoWidth,omitempty"` - VideoHeight int `json:"videoHeight,omitempty"` - FPS float64 `json:"fps,omitempty"` - Resolution string `json:"resolution,omitempty"` - - SourceURL string `json:"sourceUrl,omitempty"` - PreviewClips []previewClip `json:"previewClips,omitempty"` - PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"` - AI *aiAnalysisMeta `json:"ai,omitempty"` - + Version int `json:"version"` UpdatedAtUnix int64 `json:"updatedAtUnix"` + + File videoFileMeta `json:"file"` + Media videoMediaMeta `json:"media"` + Preview previewMeta `json:"preview,omitempty"` + Analysis analysisMeta `json:"analysis,omitempty"` + Postwork postworkMeta `json:"postwork,omitempty"` } type aiSegmentMeta struct { @@ -66,54 +117,20 @@ type aiAnalysisMeta struct { AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } -// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime) func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) { - b, err := os.ReadFile(metaPath) - if err != nil || len(b) == 0 { + m, ok := readVideoMetaIfValid(metaPath, fi) + if !ok || m == nil { return 0, 0, 0, 0, false } - - // 1) Neues Format (oder v1 mit gleichen Feldern) - var m videoMeta - if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) { - if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { - return 0, 0, 0, 0, false - } - if m.DurationSeconds <= 0 { - return 0, 0, 0, 0, false - } - return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true - } - - // 2) Fallback: ganz altes v1-Format (nur Duration etc.) - var m1 struct { - Version int `json:"version"` - DurationSeconds float64 `json:"durationSeconds"` - FileSize int64 `json:"fileSize"` - FileModUnix int64 `json:"fileModUnix"` - UpdatedAtUnix int64 `json:"updatedAtUnix"` - } - if err := json.Unmarshal(b, &m1); err != nil { - return 0, 0, 0, 0, false - } - if m1.Version != 1 { - return 0, 0, 0, 0, false - } - if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() { - return 0, 0, 0, 0, false - } - if m1.DurationSeconds <= 0 { - return 0, 0, 0, 0, false - } - return m1.DurationSeconds, 0, 0, 0, true + return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true } func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) - if !ok || m == nil || m.DurationSeconds <= 0 { + if !ok || m == nil || m.Media.DurationSeconds <= 0 { return 0, false } - return m.DurationSeconds, true + return m.Media.DurationSeconds, true } func metaJSONPathForAssetID(assetID string) (string, error) { @@ -128,14 +145,12 @@ func metaJSONPathForAssetID(assetID string) (string, error) { } func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) { - // assetID aus Dateiname stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } - // sanitize wie bei deinen generated Ordnern var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { @@ -147,12 +162,10 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo return nil, false } - // 1) valid meta vorhanden? if m, ok := readVideoMetaIfValid(metaPath, fi); ok { return m, true } - // 2) sonst neu erzeugen (mit Concurrency-Limit) if ctx == nil { ctx = context.Background() } @@ -166,38 +179,26 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo defer durSem.Release() } - // Dauer dur, derr := durationSecondsCached(cctx, fullPath) if derr != nil || dur <= 0 { return nil, false } - // Video props w, h, fps, perr := probeVideoProps(cctx, fullPath) if perr != nil { - // width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich w, h, fps = 0, 0, 0 } - // meta dir anlegen _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) - m := &videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), + if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil { + return nil, false } - b, _ := json.MarshalIndent(m, "", " ") - b = append(b, '\n') - _ = atomicWriteFile(metaPath, b) // best effort + m, ok := readVideoMetaLoose(metaPath) + if !ok || m == nil { + return nil, false + } return m, true } @@ -211,45 +212,41 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str return } - // Stat fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() { return } - // Größe immer mitgeben (macht Sort/Anzeige einfacher) if job.SizeBytes <= 0 { job.SizeBytes = fi.Size() } - // Meta.json lesen/erzeugen (best effort) m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL) if !ok || m == nil { return } - // Optional: komplettes Meta mitsenden job.Meta = m - // Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem) - if job.DurationSeconds <= 0 && m.DurationSeconds > 0 { - job.DurationSeconds = m.DurationSeconds + if strings.Contains(strings.TrimSpace(fullPath), "alice_kosmos_03_24_2026__03-48-34") { + b, _ := json.MarshalIndent(m, "", " ") + fmt.Println("ATTACH META SNAPSHOT:", string(b)) } - if job.VideoWidth <= 0 && m.VideoWidth > 0 { - job.VideoWidth = m.VideoWidth + + if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 { + job.DurationSeconds = m.Media.DurationSeconds } - if job.VideoHeight <= 0 && m.VideoHeight > 0 { - job.VideoHeight = m.VideoHeight + if job.VideoWidth <= 0 && m.Media.Video.Width > 0 { + job.VideoWidth = m.Media.Video.Width } - if job.FPS <= 0 && m.FPS > 0 { - job.FPS = m.FPS + if job.VideoHeight <= 0 && m.Media.Video.Height > 0 { + job.VideoHeight = m.Media.Video.Height + } + if job.FPS <= 0 && m.Media.Video.FPS > 0 { + job.FPS = m.Media.Video.FPS } } -// ensureVideoMetaForFileBestEffort: -// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile -// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß: -// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können. func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) { fullPath = strings.TrimSpace(fullPath) if fullPath == "" { @@ -261,23 +258,21 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour return nil, false } - // 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg) if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { return m, true } - // 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren dur := durationSecondsCacheOnly(fullPath, fi) if dur <= 0 { return nil, false } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) - assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } + assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false @@ -291,7 +286,6 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL) - // nochmal lesen/validieren if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil { return m, true } @@ -309,63 +303,162 @@ func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) { if err := json.Unmarshal(b, &m); err != nil { return nil, false } - if m.Version != 1 && m.Version != 2 { + if m.Version != 3 { return nil, false } - if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { + if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() { return nil, false } - if m.DurationSeconds <= 0 { + if m.Media.DurationSeconds <= 0 { return nil, false } return &m, true } +func readVideoMetaLoose(metaPath string) (*videoMeta, bool) { + b, err := os.ReadFile(metaPath) + if err != nil || len(b) == 0 { + return nil, false + } + + var m videoMeta + if err := json.Unmarshal(b, &m); err != nil { + return nil, false + } + + if m.Version == 0 { + m.Version = 3 + } + + return &m, true +} + +func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta { + if existing == nil && incoming == nil { + return nil + } + if existing == nil { + cp := *incoming + return &cp + } + if incoming == nil { + cp := *existing + return &cp + } + + out := *incoming + + if strings.TrimSpace(out.Path) == "" { + out.Path = existing.Path + } + if out.Count <= 0 { + out.Count = existing.Count + } + if out.Cols <= 0 { + out.Cols = existing.Cols + } + if out.Rows <= 0 { + out.Rows = existing.Rows + } + if out.StepSeconds <= 0 { + out.StepSeconds = existing.StepSeconds + } + if out.CellWidth <= 0 { + out.CellWidth = existing.CellWidth + } + if out.CellHeight <= 0 { + out.CellHeight = existing.CellHeight + } + + return &out +} + +func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool { + if ai == nil { + return false + } + if strings.TrimSpace(ai.Goal) != "" { + return true + } + if strings.TrimSpace(ai.Mode) != "" { + return true + } + if len(ai.Hits) > 0 { + return true + } + if len(ai.Segments) > 0 { + return true + } + if ai.AnalyzedAtUnix > 0 { + return true + } + return false +} + func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { return "", false } - u := strings.TrimSpace(m.SourceURL) + u := strings.TrimSpace(m.File.SourceURL) if u == "" { return "", false } return u, true } -// Voll-Write (wenn du dur + props schon hast) func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), - - PreviewClips: nil, - PreviewSprite: nil, - AI: nil, + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, } if existing != nil { - m.PreviewClips = existing.PreviewClips - m.PreviewSprite = existing.PreviewSprite - m.AI = existing.AI + m.Preview = existing.Preview + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork + + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } } + buf, err := json.Marshal(m) if err != nil { return err @@ -378,33 +471,63 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64 if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) + var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - PreviewClips: clips, - UpdatedAtUnix: time.Now().Unix(), + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Preview: previewMeta{ + Clips: clips, + Thumb: buildPreviewThumbMeta(assetID), + }, } if existing != nil { - m.AI = existing.AI + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork + + if existing.Preview.Sprite != nil && m.Preview.Sprite == nil { + m.Preview.Sprite = existing.Preview.Sprite + } + if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { + m.Preview.Thumb = existing.Preview.Thumb + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } } - // ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen - if existing != nil && existing.PreviewSprite != nil { - m.PreviewSprite = existing.PreviewSprite - } buf, err := json.Marshal(m) if err != nil { return err @@ -427,35 +550,67 @@ func writeVideoMetaWithPreviewClipsAndSprite( if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) + + var existing *videoMeta + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { + existing = old + } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - PreviewClips: clips, - PreviewSprite: sprite, - UpdatedAtUnix: time.Now().Unix(), + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Preview: previewMeta{ + Clips: clips, + Thumb: buildPreviewThumbMeta(assetID), + Sprite: sprite, + }, } - if sprite == nil { - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil { - m.PreviewSprite = old.PreviewSprite - } - } - if len(clips) == 0 { - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 { - m.PreviewClips = old.PreviewClips - } - } + if existing != nil { + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil { - m.AI = old.AI + m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite) + + if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { + m.Preview.Thumb = existing.Preview.Thumb + } + if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 { + m.Preview.Clips = existing.Preview.Clips + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } + if m.Media.DurationSeconds <= 0 { + m.Media.DurationSeconds = existing.Media.DurationSeconds + } } buf, err := json.Marshal(m) @@ -466,7 +621,6 @@ func writeVideoMetaWithPreviewClipsAndSprite( return atomicWriteFile(metaPath, buf) } -// Duration-only Write (ohne props) func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) } @@ -477,7 +631,6 @@ func generatedMetaFile(assetID string) (string, error) { return "", fmt.Errorf("empty assetID") } - // exakt wie beim Schreiben normalisieren id, err := sanitizeID(assetID) if err != nil || id == "" { return "", fmt.Errorf("invalid assetID: %w", err) @@ -486,7 +639,6 @@ func generatedMetaFile(assetID string) (string, error) { return metaJSONPathForAssetID(id) } -// ✅ Neu: /generated/meta//... func generatedDirForID(id string) (string, error) { id, err := sanitizeID(id) if err != nil { @@ -544,6 +696,27 @@ func generatedPreviewSpriteFile(id string) (string, error) { return filepath.Join(dir, "preview-sprite.jpg"), nil } +func buildPreviewThumbMeta(assetID string) *previewThumbMeta { + assetID = stripHotPrefix(strings.TrimSpace(assetID)) + if assetID == "" { + return nil + } + + thumbPath, err := generatedThumbFile(assetID) + if err != nil || strings.TrimSpace(thumbPath) == "" { + return nil + } + + if _, err := os.Stat(thumbPath); err != nil { + return nil + } + + return &previewThumbMeta{ + Path: "/api/generated/cover?id=" + assetID, + Exists: true, + } +} + func ensureGeneratedDirs() error { root, err := generatedMetaRoot() if err != nil { @@ -581,44 +754,57 @@ func writeVideoMetaAI( } var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), - - PreviewClips: nil, - PreviewSprite: nil, - AI: ai, + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Analysis: analysisMeta{ + AI: ai, + }, } if existing != nil { - m.PreviewClips = existing.PreviewClips - m.PreviewSprite = existing.PreviewSprite - if m.VideoWidth <= 0 { - m.VideoWidth = existing.VideoWidth + m.Preview = existing.Preview + m.Postwork = existing.Postwork + + if !hasAIAnalysisMeta(ai) && hasAIAnalysisMeta(existing.Analysis.AI) { + m.Analysis = existing.Analysis } - if m.VideoHeight <= 0 { - m.VideoHeight = existing.VideoHeight + + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width } - if m.FPS <= 0 { - m.FPS = existing.FPS + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height } - if m.Resolution == "" { - m.Resolution = existing.Resolution + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS } - if m.SourceURL == "" { - m.SourceURL = existing.SourceURL + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.DurationSeconds <= 0 { + m.Media.DurationSeconds = existing.Media.DurationSeconds } } @@ -670,15 +856,103 @@ func writeVideoAIForFile( return writeVideoMetaAI( metaPath, fi, - m.DurationSeconds, - m.VideoWidth, - m.VideoHeight, - m.FPS, + m.Media.DurationSeconds, + m.Media.Video.Width, + m.Media.Video.Height, + m.Media.Video.FPS, sourceURL, ai, ) } +func writeVideoMetaPostworkEntry( + metaPath string, + fi os.FileInfo, + entry postworkHistoryEntry, +) error { + if strings.TrimSpace(metaPath) == "" { + return nil + } + + var existing videoMeta + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { + existing = *old + } + + // Datei-Metadaten immer aktualisieren + existing.Version = 3 + existing.UpdatedAtUnix = time.Now().Unix() + existing.File.Size = fi.Size() + existing.File.ModUnix = fi.ModTime().Unix() + + queue := strings.TrimSpace(strings.ToLower(entry.Queue)) + phase := strings.TrimSpace(strings.ToLower(entry.Phase)) + if queue == "" { + queue = "postwork" + } + if phase == "" { + phase = "postwork" + } + entry.Queue = queue + entry.Phase = phase + if entry.TS <= 0 { + entry.TS = time.Now().Unix() + } + + history := existing.Postwork.History + replaced := false + + for i := range history { + if strings.EqualFold(history[i].Queue, entry.Queue) && + strings.EqualFold(history[i].Phase, entry.Phase) { + // nur ersetzen, wenn neuer oder gleich neu + if history[i].TS <= entry.TS { + history[i] = entry + } + replaced = true + break + } + } + + if !replaced { + history = append(history, entry) + } + + existing.Postwork.History = history + + if existing.Postwork.Completed == nil { + existing.Postwork.Completed = &postworkCompletedMeta{} + } + + if strings.EqualFold(entry.State, "done") { + switch queue { + case "postwork": + switch phase { + case "postwork", "probe", "remuxing", "moving", "meta": + existing.Postwork.Completed.Meta = true + case "assets", "teaser": + existing.Postwork.Completed.Teaser = true + case "thumb": + existing.Postwork.Completed.Thumb = true + case "sprites", "sprite": + existing.Postwork.Completed.Sprites = true + } + case "enrich": + switch phase { + case "analyze", "analysis", "ai": + existing.Postwork.Completed.Analyze = true + } + } + } + + buf, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + buf = append(buf, '\n') + return atomicWriteFile(metaPath, buf) +} + func readVideoMetaAI(metaPath string) (map[string]any, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { @@ -692,10 +966,70 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) { return nil, false } - ai, ok := m["ai"].(map[string]any) + rawAnalysis, ok := m["analysis"].(map[string]any) + if !ok || rawAnalysis == nil { + return nil, false + } + + ai, ok := rawAnalysis["ai"].(map[string]any) if !ok || ai == nil { return nil, false } return ai, true } + +func writePostworkEntryForFile( + fullPath string, + queue string, + phase string, + state string, + label string, + position int, + waiting int, + running int, + maxParallel int, +) error { + fullPath = strings.TrimSpace(fullPath) + if fullPath == "" { + return fmt.Errorf("fullPath fehlt") + } + + fi, err := os.Stat(fullPath) + if err != nil || fi == nil || fi.IsDir() { + return fmt.Errorf("datei nicht gefunden") + } + + stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) + assetID := stripHotPrefix(strings.TrimSpace(stem)) + if assetID == "" { + return fmt.Errorf("asset id fehlt") + } + + assetID, err = sanitizeID(assetID) + if err != nil { + return err + } + + metaPath, err := metaJSONPathForAssetID(assetID) + if err != nil { + return err + } + + // sorgt dafür, dass meta.json grundsätzlich existiert + if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok { + return fmt.Errorf("meta konnte nicht erzeugt werden") + } + + return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{ + Queue: queue, + Phase: phase, + State: state, + Label: label, + Position: position, + Waiting: waiting, + Running: running, + MaxParallel: maxParallel, + TS: time.Now().Unix(), + }) +} diff --git a/backend/performance.go b/backend/performance.go new file mode 100644 index 0000000..0532056 --- /dev/null +++ b/backend/performance.go @@ -0,0 +1,343 @@ +// backend\performance.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "math" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" + + gocpu "github.com/shirou/gopsutil/v3/cpu" + godisk "github.com/shirou/gopsutil/v3/disk" +) + +// Server-Startzeit für Perf/Uptime +var serverStartedAt = time.Now() + +var lastCPUUsageBits uint64 // atomic float64 bits + +func setLastCPUUsage(v float64) { + atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) +} + +func getLastCPUUsage() float64 { + return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) +} + +func startCPUUsageSampler(ctx context.Context) { + _, _ = gocpu.Percent(200*time.Millisecond, false) + + t := time.NewTicker(2 * time.Second) + + go func() { + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + p, err := gocpu.Percent(0, false) + if err != nil || len(p) == 0 { + continue + } + + usage := p[0] + if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 { + continue + } + if usage > 100 { + usage = 100 + } + + setLastCPUUsage(usage) + } + } + }() +} + +// ---------- Feste ffmpeg/ffprobe Limiter ---------- + +type FixedLimiter chan struct{} + +func NewFixedLimiter(n int) FixedLimiter { + if n < 1 { + n = 1 + } + return make(FixedLimiter, n) +} + +func (l FixedLimiter) Acquire(ctx context.Context) error { + if l == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + select { + case l <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (l FixedLimiter) Release() { + if l == nil { + return + } + select { + case <-l: + default: + } +} + +func (l FixedLimiter) Limit() int { + return cap(l) +} + +func (l FixedLimiter) InUse() int { + return len(l) +} + +var ( + previewSem FixedLimiter + thumbSem FixedLimiter + genSem FixedLimiter + durSem FixedLimiter +) + +func envInt(name string) (int, bool) { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return 0, false + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, false + } + return n, true +} + +func initFFmpegLimiters() { + previewN := 2 + thumbN := 4 + genN := 2 + durN := 8 + + if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 { + previewN = n + } + if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 { + thumbN = n + } + if n, ok := envInt("GEN_WORKERS"); ok && n > 0 { + genN = n + } + if n, ok := envInt("DUR_WORKERS"); ok && n > 0 { + durN = n + } + + previewSem = NewFixedLimiter(previewN) + thumbSem = NewFixedLimiter(thumbN) + genSem = NewFixedLimiter(genN) + durSem = NewFixedLimiter(durN) + + setLastCPUUsage(0) + startCPUUsageSampler(context.Background()) +} + +func buildPerfSnapshot() map[string]any { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + s := getSettings() + recordDir, _ := resolvePathRelativeToApp(s.RecordDir) + + var diskFreeBytes uint64 + var diskTotalBytes uint64 + var diskUsedPercent float64 + diskPath := recordDir + + if recordDir != "" { + if u, err := godisk.Usage(recordDir); err == nil && u != nil { + diskFreeBytes = u.Free + diskTotalBytes = u.Total + diskUsedPercent = u.UsedPercent + } + } + + // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) + pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() + + resp := map[string]any{ + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "serverMs": time.Now().UTC().UnixMilli(), + "uptimeSec": time.Since(serverStartedAt).Seconds(), + "cpuPercent": func() float64 { + v := getLastCPUUsage() + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v + }(), + "diskPath": diskPath, + "diskFreeBytes": diskFreeBytes, + "diskTotalBytes": diskTotalBytes, + "diskUsedPercent": diskUsedPercent, + "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, + + "diskPauseBelowGB": pauseGB, + "diskResumeAboveGB": resumeGB, + + "diskInFlightBytes": inFlight, + "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), + "diskPauseNeedBytes": pauseNeed, + "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), + "diskResumeNeedBytes": resumeNeed, + "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), + + "goroutines": runtime.NumGoroutine(), + "mem": map[string]any{ + "alloc": ms.Alloc, + "heapAlloc": ms.HeapAlloc, + "heapInuse": ms.HeapInuse, + "sys": ms.Sys, + "numGC": ms.NumGC, + }, + } + + sem := map[string]any{} + if previewSem != nil { + sem["preview"] = map[string]any{ + "inUse": previewSem.InUse(), + "limit": previewSem.Limit(), + } + } + if thumbSem != nil { + sem["thumb"] = map[string]any{ + "inUse": thumbSem.InUse(), + "limit": thumbSem.Limit(), + } + } + if genSem != nil { + sem["gen"] = map[string]any{ + "inUse": genSem.InUse(), + "limit": genSem.Limit(), + } + } + if durSem != nil { + sem["dur"] = map[string]any{ + "inUse": durSem.InUse(), + "limit": durSem.Limit(), + } + } + if len(sem) > 0 { + resp["sem"] = sem + } + + return resp +} + +func pingHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func perfHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + resp := buildPerfSnapshot() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func perfStreamHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) + return + } + + ms := 5000 + if q := r.URL.Query().Get("ms"); q != "" { + if v, err := strconv.Atoi(q); err == nil { + if v < 1000 { + v = 1000 + } + if v > 30000 { + v = 30000 + } + ms = v + } + } + + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ctx := r.Context() + + send := func() error { + payload := buildPerfSnapshot() + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + return err + } + + _, _ = io.WriteString(w, "event: perf\n") + _, _ = io.WriteString(w, "data: ") + _, _ = w.Write(buf.Bytes()) + _, _ = io.WriteString(w, "\n") + fl.Flush() + return nil + } + + _ = send() + + t := time.NewTicker(time.Duration(ms) * time.Millisecond) + hb := time.NewTicker(15 * time.Second) + defer t.Stop() + defer hb.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + _ = send() + case <-hb.C: + _, _ = io.WriteString(w, ": keep-alive\n\n") + fl.Flush() + } + } +} diff --git a/backend/postwork.go b/backend/postwork.go index 7dd6aa5..20c68b2 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -27,6 +27,7 @@ type PostWorkQueue struct { // ✅ für UI: Warteschlange + Running-Keys tracken waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) + cancelByKey map[string]context.CancelFunc } func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { @@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { waitingKeys: make([]string, 0, queueSize), runningKeys: make(map[string]struct{}), + cancelByKey: make(map[string]context.CancelFunc), } } @@ -80,6 +82,23 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool { } } +func (pq *PostWorkQueue) Cancel(key string) bool { + if strings.TrimSpace(key) == "" { + return false + } + + pq.mu.Lock() + cancel := pq.cancelByKey[key] + pq.mu.Unlock() + + if cancel != nil { + cancel() + return true + } + + return false +} + func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) { for i, k := range pq.waitingKeys { if k == key { @@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) { // 3) Optional: Task timeout (gegen hängende ffmpeg) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) - defer cancel() + + pq.mu.Lock() + pq.cancelByKey[task.Key] = cancel + pq.mu.Unlock() + + defer func() { + cancel() + + pq.mu.Lock() + delete(pq.cancelByKey, task.Key) + pq.mu.Unlock() + }() // 4) Run if task.Run != nil { @@ -291,9 +321,9 @@ func startEnrichStatusRefresher() { continue } - label := "Warte auf Sprite / AI / Meta…" + label := "Wartet" if st.State == "running" { - label = "Ergänze Sprite / AI / Meta…" + label = "Wird verarbeitet…" } publishFinishedPostworkStateForJob( @@ -388,10 +418,6 @@ func startPostWorkStatusRefresher() { } } jobsMu.Unlock() - - for _, job := range changedJobs { - publishJobUpsert(job) - } } }() } diff --git a/backend/preview.go b/backend/preview.go index 548c32d..91ca494 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "crypto/sha1" + "embed" "encoding/hex" "encoding/json" "errors" @@ -58,6 +59,9 @@ func setCoverModelStore(s *ModelStore) { coverModelStore = s } var errCoverNotSupported = errors.New("cover not supported") +//go:embed assets/img/cb.jpg assets/img/mfc.jpg +var embeddedFallbackImages embed.FS + // ============================================================ // Covers: generated/covers/. + info.json // Routes: @@ -1323,51 +1327,121 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er } func latestPreviewSegment(previewDir string) (string, error) { - entries, err := os.ReadDir(previewDir) + segDir := previewSegmentsDir(previewDir) + entries, err := os.ReadDir(segDir) if err != nil { return "", err } + var best string for _, e := range entries { if e.IsDir() { continue } - name := e.Name() - if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") { + + name := strings.ToLower(e.Name()) + if !strings.HasPrefix(name, "seg_preview_") { continue } + if best == "" || name > best { - best = name + best = e.Name() } } + if best == "" { - return "", fmt.Errorf("kein Preview-Segment in %s", previewDir) + return "", fmt.Errorf("kein Preview-Segment in %s", segDir) } - return filepath.Join(previewDir, best), nil + return filepath.Join(segDir, best), nil +} + +func buildPreviewInputFromDir(previewDir string) (string, func(), error) { + segDir := previewSegmentsDir(previewDir) + if strings.TrimSpace(segDir) == "" { + return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir) + } + + segPath, err := latestPreviewSegment(previewDir) + if err != nil { + return "", nil, err + } + + ext := strings.ToLower(filepath.Ext(segPath)) + + // TS kann direkt gelesen werden + if ext == ".ts" { + return segPath, func() {}, nil + } + + // fMP4: init.mp4 + segment.m4s zu temp zusammensetzen + initPath := filepath.Join(segDir, "init.mp4") + if _, err := os.Stat(initPath); err != nil { + return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir) + } + + initBytes, err := os.ReadFile(initPath) + if err != nil { + return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err) + } + + segBytes, err := os.ReadFile(segPath) + if err != nil { + return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) + } + + tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4") + if err != nil { + return "", nil, err + } + tmpPath := tmp.Name() + + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + + if _, err := tmp.Write(initBytes); err != nil { + cleanup() + return "", nil, err + } + if _, err := tmp.Write(segBytes); err != nil { + cleanup() + return "", nil, err + } + if err := tmp.Close(); err != nil { + cleanup() + return "", nil, err + } + + return tmpPath, cleanup, nil } func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) { - seg, err := latestPreviewSegment(previewDir) + inputPath, cleanup, err := buildPreviewInputFromDir(previewDir) if err != nil { return nil, err } - img, err := extractLastFrameJPGScaled(seg, 320, 70) + defer cleanup() + + img, err := extractLastFrameJPGScaled(inputPath, 320, 70) if err == nil && len(img) > 0 { return img, nil } - return extractFirstFrameJPGScaled(seg, 320, 70) + return extractFirstFrameJPGScaled(inputPath, 320, 70) } func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) { - seg, err := latestPreviewSegment(previewDir) + inputPath, cleanup, err := buildPreviewInputFromDir(previewDir) if err != nil { return nil, err } - img, err := extractLastFrameJPG(seg) - if err != nil { - return extractFirstFrameJPGScaled(seg, 720, 75) + defer cleanup() + + img, err := extractLastFrameJPG(inputPath) + if err == nil && len(img) > 0 { + return img, nil } - return img, nil + return extractFirstFrameJPGScaled(inputPath, 720, 75) } func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) { @@ -1431,6 +1505,99 @@ func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) { _, _ = w.Write(b) } +func detectPreviewProvider(job *RecordJob, outPath string) string { + if job != nil { + sourceURL := strings.ToLower(strings.TrimSpace(job.SourceURL)) + + switch { + case strings.Contains(sourceURL, "chaturbate.com"): + return "chaturbate" + case strings.Contains(sourceURL, "myfreecams.com"): + return "mfc" + } + } + + modelKey := strings.TrimSpace(inferModelFromThumbLike(outPath)) + if modelKey == "" || coverModelStore == nil { + return "" + } + modelKey = strings.ToLower(modelKey) + + models := coverModelStore.List() + for _, m := range models { + if strings.ToLower(strings.TrimSpace(m.ModelKey)) != modelKey { + continue + } + + host := strings.ToLower(strings.TrimSpace(m.Host)) + input := strings.ToLower(strings.TrimSpace(m.Input)) + + switch { + case strings.Contains(host, "chaturbate"), strings.Contains(input, "chaturbate.com"): + return "chaturbate" + case strings.Contains(host, "myfreecams"), strings.Contains(input, "myfreecams.com"): + return "mfc" + } + } + + return "" +} + +func fallbackPreviewImageAssetPathForProvider(provider string) (string, error) { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "chaturbate": + return "assets/img/cb.jpg", nil + case "mfc": + return "assets/img/mfc.jpg", nil + default: + return "", fmt.Errorf("unknown provider") + } +} + +func serveFallbackPreviewImage(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string) bool { + provider := detectPreviewProvider(job, outPath) + if provider == "" { + return false + } + + assetPath, err := fallbackPreviewImageAssetPathForProvider(provider) + if err != nil { + return false + } + + b, err := embeddedFallbackImages.ReadFile(assetPath) + if err != nil || len(b) == 0 { + return false + } + + ext := strings.ToLower(filepath.Ext(assetPath)) + switch ext { + case ".png": + w.Header().Set("Content-Type", "image/png") + default: + w.Header().Set("Content-Type", "image/jpeg") + } + + w.Header().Set("Cache-Control", "public, max-age=60") + w.Header().Set("X-Content-Type-Options", "nosniff") + + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return true + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) + return true +} + +func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string, label string, status int) { + if serveFallbackPreviewImage(w, r, job, outPath) { + return + } + servePreviewStatusSVG(w, label, status) +} + func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) { jobsMu.Lock() job := jobs[id] @@ -1461,7 +1628,7 @@ func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) { } } - servePreviewStatusSVG(w, "Preview", http.StatusOK) + serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) return } @@ -1552,23 +1719,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri previewDir := strings.TrimSpace(j.PreviewDir) if previewDir != "" { img, genErr = extractLastFrameFromPreviewDirJPG(previewDir) - } - - if genErr != nil || len(img) == 0 { - outPath := strings.TrimSpace(j.Output) - if outPath != "" { - outPath = filepath.Clean(outPath) - if !filepath.IsAbs(outPath) { - if abs, err := resolvePathRelativeToApp(outPath); err == nil { - outPath = abs - } - } - if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 { - img, genErr = extractLastFrameJPG(outPath) - if genErr != nil { - img, _ = extractFirstFrameJPGScaled(outPath, 720, 75) - } - } + if genErr != nil { + //fmt.Println("preview from previewDir failed:", previewDir, genErr) } } @@ -1594,16 +1746,15 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri jobsMu.Unlock() if state == "private" { - servePreviewStatusSVG(w, "Private", http.StatusOK) + serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK) return } if state == "offline" { - servePreviewStatusSVG(w, "Offline", http.StatusOK) + serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK) return } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) + serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) return } @@ -1738,6 +1889,11 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri return } + // Wenn noch kein generiertes preview.jpg existiert, Provider-Fallbackbild liefern + if serveFallbackPreviewImage(w, r, nil, outPath) { + return + } + genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() diff --git a/backend/record.go b/backend/record.go index cd8dcff..efb2218 100644 --- a/backend/record.go +++ b/backend/record.go @@ -57,17 +57,19 @@ type previewSpriteMetaResp struct { StepSeconds float64 `json:"stepSeconds,omitempty"` } +type finishedPhaseTruth struct { + MetaReady bool `json:"metaReady"` + TeaserReady bool `json:"teaserReady"` + ThumbReady bool `json:"thumbReady"` + SpritesReady bool `json:"spritesReady"` + AnalyzeReady bool `json:"analyzeReady"` +} + type doneMetaFileResp struct { - File string `json:"file"` - MetaExists bool `json:"metaExists"` - DurationSeconds float64 `json:"durationSeconds,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - FPS float64 `json:"fps,omitempty"` - SourceURL string `json:"sourceUrl,omitempty"` - PreviewSprite previewSpriteMetaResp `json:"previewSprite"` - AI any `json:"ai,omitempty"` - Error string `json:"error,omitempty"` + File string `json:"file"` + MetaExists bool `json:"metaExists"` + Meta *videoMeta `json:"meta,omitempty"` + Error string `json:"error,omitempty"` } type doneMetaResp struct { @@ -90,6 +92,10 @@ type undoDeleteToken struct { File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" } +type bulkFilesRequest struct { + Files []string `json:"files"` +} + func encodeUndoDeleteToken(t undoDeleteToken) (string, error) { b, err := json.Marshal(t) if err != nil { @@ -160,6 +166,303 @@ func recordPrepareSplit(w http.ResponseWriter, r *http.Request) { }) } +func handleDeleteMany(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req bulkFilesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + if len(req.Files) == 0 { + http.Error(w, "missing files", http.StatusBadRequest) + return + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil { + http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + if strings.TrimSpace(doneAbs) == "" { + http.Error(w, "doneDir ist leer", http.StatusBadRequest) + return + } + + keepRoot := filepath.Join(doneAbs, "keep") + + type itemResult struct { + File string `json:"file"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + From string `json:"from,omitempty"` + } + + results := make([]itemResult, 0, len(req.Files)) + deletedCount := 0 + seen := make(map[string]struct{}, len(req.Files)) + + for _, raw := range req.Files { + file := strings.TrimSpace(raw) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + + if !isAllowedVideoExt(file) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "nicht erlaubt", + }) + continue + } + + // laufende/queued Nacharbeit abbrechen + cancelDeferredEnrichForFile(file) + + target, from, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei nicht gefunden", + }) + continue + } + if fi != nil && fi.IsDir() { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "ist ein verzeichnis", + From: from, + }) + continue + } + + if err := removeWithRetry(target); err != nil { + if runtime.GOOS == "windows" && isSharingViolation(err) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei wird gerade verwendet", + From: from, + }) + continue + } + + results = append(results, itemResult{ + File: file, + OK: false, + Error: err.Error(), + From: from, + }) + continue + } + + purgeDurationCacheForPath(target) + removeJobsByOutputBasename(file) + + // generated assets aufräumen + id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + if strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } + + // optional: leeren Keep-Unterordner nicht hart notwendig, aber sauber + if from == "keep" { + dir := filepath.Dir(target) + if strings.HasPrefix(strings.ToLower(filepath.Clean(dir)), strings.ToLower(filepath.Clean(keepRoot))) { + _ = os.Remove(dir) + } + } + + deletedCount++ + results = append(results, itemResult{ + File: file, + OK: true, + From: from, + }) + } + + if deletedCount > 0 { + notifyDoneChanged() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": deletedCount > 0, + "deleted": deletedCount, + "results": results, + }) +} + +func handleKeepMany(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req bulkFilesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + if len(req.Files) == 0 { + http.Error(w, "missing files", http.StatusBadRequest) + return + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil { + http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + if strings.TrimSpace(doneAbs) == "" { + http.Error(w, "doneDir ist leer", http.StatusBadRequest) + return + } + + keepRoot := filepath.Join(doneAbs, "keep") + if err := os.MkdirAll(keepRoot, 0o755); err != nil { + http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + type itemResult struct { + File string `json:"file"` + NewFile string `json:"newFile,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + + results := make([]itemResult, 0, len(req.Files)) + keptCount := 0 + seen := make(map[string]struct{}, len(req.Files)) + + for _, raw := range req.Files { + file := strings.TrimSpace(raw) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + + if !isAllowedVideoExt(file) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "nicht erlaubt", + }) + continue + } + + cancelDeferredEnrichForFile(file) + + src, from, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei nicht gefunden", + }) + continue + } + + // Bereits in keep -> nichts tun, aber als ok zurückgeben + if from == "keep" { + results = append(results, itemResult{ + File: file, + NewFile: file, + OK: true, + }) + continue + } + + if fi == nil || fi.IsDir() { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "ist ein verzeichnis", + }) + continue + } + + modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs) + dstDir := keepRoot + if modelKey != "" { + dstDir = filepath.Join(keepRoot, modelKey) + } + + if err := os.MkdirAll(dstDir, 0o755); err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "keep subdir erstellen fehlgeschlagen: " + err.Error(), + }) + continue + } + + dst, err := uniqueDestPath(dstDir, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "zielname nicht verfügbar: " + err.Error(), + }) + continue + } + + if err := renameWithRetryAggressive(src, dst); err != nil { + if runtime.GOOS == "windows" && isSharingViolation(err) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei wird gerade verwendet", + }) + continue + } + + results = append(results, itemResult{ + File: file, + OK: false, + Error: err.Error(), + }) + continue + } + + keptCount++ + results = append(results, itemResult{ + File: file, + NewFile: filepath.Base(dst), + OK: true, + }) + } + + if keptCount > 0 { + notifyDoneChanged() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": keptCount > 0, + "kept": keptCount, + "results": results, + }) +} + // ---------------- Preview sprite truth (shared) ---------------- type previewSpriteMetaFileInfo struct { @@ -185,7 +488,12 @@ func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileIn return out, false } - ps, ok := m["previewSprite"].(map[string]any) + previewMap, ok := m["preview"].(map[string]any) + if !ok || previewMap == nil { + return out, false + } + + ps, ok := previewMap["sprite"].(map[string]any) if !ok || ps == nil { return out, false } @@ -314,14 +622,11 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp { return out } - metaPath, err := generatedMetaFile(id) - if err != nil || strings.TrimSpace(metaPath) == "" { + _, _, _, spriteFile, metaPath, err := assetPathsForID(id) + if err != nil { return out } - genDir := filepath.Dir(metaPath) - spriteFile := filepath.Join(genDir, "preview-sprite.jpg") - fi, err := os.Stat(spriteFile) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return out @@ -348,11 +653,154 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp { return out } +func numberFromAny(v any) (float64, bool) { + switch x := v.(type) { + case float32: + return float64(x), true + case float64: + return x, true + case int: + return float64(x), true + case int8: + return float64(x), true + case int16: + return float64(x), true + case int32: + return float64(x), true + case int64: + return float64(x), true + case uint: + return float64(x), true + case uint8: + return float64(x), true + case uint16: + return float64(x), true + case uint32: + return float64(x), true + case uint64: + return float64(x), true + case json.Number: + if f, err := x.Float64(); err == nil { + return f, true + } + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, false + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f, true + } + } + return 0, false +} + +func finishedPhaseTruthForID(id string) finishedPhaseTruth { + var out finishedPhaseTruth + + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") { + return out + } + + _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) + if err != nil || strings.TrimSpace(metaPath) == "" { + return out + } + + b, err := os.ReadFile(metaPath) + if err == nil && len(b) > 0 { + var m map[string]any + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.UseNumber() + if err := dec.Decode(&m); err == nil { + media, _ := m["media"].(map[string]any) + if media != nil { + if d, ok := numberFromAny(media["durationSeconds"]); ok && d > 0 { + video, _ := media["video"].(map[string]any) + if video != nil { + if w, wok := numberFromAny(video["width"]); wok && w > 0 { + if h, hok := numberFromAny(video["height"]); hok && h > 0 { + out.MetaReady = true + } + } + } + } + } + + preview, _ := m["preview"].(map[string]any) + if preview != nil { + if clips, ok := preview["clips"].([]any); ok && len(clips) > 0 { + out.TeaserReady = true + } + + if thumb, ok := preview["thumb"].(map[string]any); ok && thumb != nil { + if exists, ok := thumb["exists"].(bool); ok && exists { + out.ThumbReady = true + } + if p, ok := thumb["path"].(string); ok && strings.TrimSpace(p) != "" { + out.ThumbReady = true + } + } + + if sprite, ok := preview["sprite"].(map[string]any); ok && sprite != nil { + if p, ok := sprite["path"].(string); ok && strings.TrimSpace(p) != "" { + out.SpritesReady = true + } + } + } + + analysis, _ := m["analysis"].(map[string]any) + if analysis != nil { + if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 { + out.AnalyzeReady = true + } + } + } + } + + if !out.ThumbReady { + if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.ThumbReady = true + } + } + + if !out.SpritesReady { + if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.SpritesReady = true + } + } + + if !out.TeaserReady { + if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.TeaserReady = true + } + } + + return out +} + func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) { if resp == nil { return } - resp.PreviewSprite = previewSpriteTruthForID(id) + + ps := previewSpriteTruthForID(id) + if !ps.Exists { + return + } + + if resp.Meta == nil { + resp.Meta = &videoMeta{} + } + + resp.Meta.Preview.Sprite = &previewSpriteMeta{ + Path: ps.Path, + Count: ps.Count, + Cols: ps.Cols, + Rows: ps.Rows, + StepSeconds: ps.StepSeconds, + } } // robust meta setter into RecordJob.Meta (any/string/[]byte/typed map) @@ -502,9 +950,6 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) { meta = map[string]any{} } - delete(meta, "previewScrubberPath") - delete(meta, "previewScrubberCount") - psMap := map[string]any{"exists": ps.Exists} if ps.Exists { psMap["path"] = ps.Path @@ -521,8 +966,81 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) { psMap["stepSeconds"] = ps.StepSeconds } } - meta["previewSprite"] = psMap + previewMap, _ := meta["preview"].(map[string]any) + if previewMap == nil { + previewMap = map[string]any{} + } + previewMap["sprite"] = psMap + meta["preview"] = previewMap + + setStructFieldJSONMap(fv, meta) +} + +func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) { + if j == nil { + return + } + + outPath := strings.TrimSpace(j.Output) + if outPath == "" { + return + } + + base := filepath.Base(outPath) + id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) + id = strings.TrimSpace(id) + if id == "" { + return + } + + truth := finishedPhaseTruthForID(id) + + 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 + } + + var raw any + switch fv.Kind() { + case reflect.Interface: + if fv.IsNil() { + raw = nil + } else { + raw = fv.Interface() + } + default: + raw = fv.Interface() + } + + meta := metaMapFromAny(raw) + if meta == nil { + meta = map[string]any{} + } + + postworkMap, _ := meta["postwork"].(map[string]any) + if postworkMap == nil { + postworkMap = map[string]any{} + } + + postworkMap["completed"] = map[string]any{ + "meta": truth.MetaReady, + "teaser": truth.TeaserReady, + "thumb": truth.ThumbReady, + "sprites": truth.SpritesReady, + "analyze": truth.AnalyzeReady, + } + + meta["postwork"] = postworkMap setStructFieldJSONMap(fv, meta) } @@ -1261,35 +1779,31 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { resp := doneMetaFileResp{File: filepath.Base(outPath)} id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))) - applyPreviewSpriteTruthToDoneMetaResp(id, &resp) if strings.TrimSpace(id) != "" { if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" { if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 { - resp.MetaExists = true - - if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok { - resp.DurationSeconds = dur - resp.Width = w2 - resp.Height = h2 - resp.FPS = fps2 - } - if u, ok := readVideoMetaSourceURL(mp, fi); ok { - resp.SourceURL = u - } - - if ai, ok := readVideoMetaAI(mp); ok { - resp.AI = ai + if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil { + resp.MetaExists = true + resp.Meta = meta } } } } - if resp.DurationSeconds <= 0 { + // Sprite-Wahrheit aus Dateisystem/meta ggf. ergänzen/überschreiben + applyPreviewSpriteTruthToDoneMetaResp(id, &resp) + + if resp.Meta == nil { + resp.Meta = &videoMeta{} + } + + // Fallback Duration, falls Meta fehlt oder unvollständig + if resp.Meta.Media.DurationSeconds <= 0 { pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 { - resp.DurationSeconds = d + resp.Meta.Media.DurationSeconds = d } } @@ -1715,6 +2229,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } applyPreviewSpriteTruthToRecordJobMeta(&c) + applyFinishedPhaseTruthToRecordJobMeta(&c) out = append(out, &c) } @@ -1763,6 +2278,62 @@ func renameWithRetryAggressive(src, dst string) error { return lastErr } +func cancelDeferredEnrichForFile(file string) { + file = strings.TrimSpace(file) + if file == "" { + return + } + + id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + id = strings.TrimSpace(id) + if id == "" { + return + } + + enrichKey := "enrich:" + id + st := enrichQ.StatusForKey(enrichKey) + + switch st.State { + case "queued": + if enrichQ.RemoveQueued(enrichKey) { + // optional: stale badge sofort entfernen + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "missing", + Phase: "assets", + Label: "", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } + } + + case "running": + if enrichQ.Cancel(enrichKey) { + // ffmpeg / reads / handles kurz auslaufen lassen + time.Sleep(350 * time.Millisecond) + + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "missing", + Phase: "assets", + Label: "", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } + } + } +} + func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) @@ -1779,6 +2350,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2046,6 +2620,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2116,6 +2693,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2222,6 +2802,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } + // optional, aber sinnvoll + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 172645b..74bc7b4 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -9,7 +9,10 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "path/filepath" + "sort" "strconv" "strings" "time" @@ -17,6 +20,25 @@ import ( "github.com/grafov/m3u8" ) +func previewSegmentsDir(previewDir string) string { + previewDir = strings.TrimSpace(previewDir) + if previewDir == "" { + return "" + } + return filepath.Join(previewDir, "segments") +} + +func ensurePreviewSegmentsDir(previewDir string) (string, error) { + dir := previewSegmentsDir(previewDir) + if strings.TrimSpace(dir) == "" { + return "", fmt.Errorf("previewDir leer") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + // --- DVR-ähnlicher Recorder-Ablauf --- // Entspricht grob dem RecordStream aus dem Channel-Snippet: func RecordStream( @@ -28,8 +50,6 @@ func RecordStream( httpCookie string, job *RecordJob, ) error { - // 1) Seite laden - // Domain sauber zusammenbauen (mit/ohne Slash) base := strings.TrimRight(domain, "/") pageURL := base + "/" + username @@ -38,58 +58,155 @@ func RecordStream( return fmt.Errorf("seite laden: %w", err) } - // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream) hlsURL, err := ParseStream(body) if err != nil { return fmt.Errorf("stream-parsing: %w", err) } - // 3) Playlist holen (wie stream.GetPlaylist im DVR) playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) if err != nil { return fmt.Errorf("playlist abrufen: %w", err) } - // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input + var previewDir string + var segmentsDir string + if job != nil { + assetID := assetIDForJob(job) + if assetID != "" { + if dir, derr := ensureGeneratedDir(assetID); derr == nil { + previewDir = strings.TrimSpace(dir) + + if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil { + segmentsDir = strings.TrimSpace(sdir) + } + } + } + jobsMu.Lock() job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent + if previewDir != "" { + job.PreviewDir = previewDir + } jobsMu.Unlock() + + if previewDir != "" { + startLiveThumbJPGLoop(ctx, job) + } + } + + if segmentsDir != "" { + defer func() { + if err := os.RemoveAll(segmentsDir); err != nil { + //fmt.Println("preview segments cleanup failed:", segmentsDir, err) + } + }() } - // 4) Datei öffnen file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("datei erstellen: %w", err) } - defer func() { _ = file.Close() }() - // live size tracking (für UI) var written int64 var lastPush time.Time var lastBytes int64 - published := false - // 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR - err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { - // Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben + trimPreviewSegments := func(dir string, keep int) { + if strings.TrimSpace(dir) == "" || keep <= 0 { + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + type item struct { + name string + mod time.Time + } + + items := make([]item, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + lower := strings.ToLower(name) + + if lower == "init.mp4" { + continue + } + if !strings.HasPrefix(lower, "seg_preview_") { + continue + } + + info, err := e.Info() + if err != nil { + continue + } + + items = append(items, item{ + name: name, + mod: info.ModTime(), + }) + } + + if len(items) <= keep { + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].mod.Before(items[j].mod) + }) + + for i := 0; i < len(items)-keep; i++ { + _ = os.Remove(filepath.Join(dir, items[i].name)) + } + } + + err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error { + if len(b) == 0 { + return nil + } + if _, err := file.Write(b); err != nil { return fmt.Errorf("schreibe segment: %w", err) } - // ✅ erst sichtbar machen, wenn wirklich Bytes geschrieben wurden + if err := file.Sync(); err != nil { + //fmt.Println("output sync failed:", outputPath, err) + } + + if segmentsDir != "" { + if err := os.MkdirAll(segmentsDir, 0o755); err == nil { + ext := ".m4s" + if len(b) > 0 && b[0] == 0x47 { + ext = ".ts" + } + + segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext) + segPath := filepath.Join(segmentsDir, segName) + + if err := os.WriteFile(segPath, b, 0o644); err == nil { + trimPreviewSegments(segmentsDir, 12) + } + } + } + if job != nil && !published { published = true _ = publishJob(job.ID) } - // ✅ live size (UI) – throttled written += int64(len(b)) if job != nil { now := time.Now() @@ -97,15 +214,14 @@ func RecordStream( jobsMu.Lock() job.SizeBytes = written jobsMu.Unlock() - publishJobUpsert(job) lastPush = now lastBytes = written } } - // Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest - _ = duration // aktuell unbenutzt + _ = duration + _ = segmentURL return nil }) if err != nil { @@ -160,11 +276,68 @@ func (p *Playlist) WatchSegments( ctx context.Context, hc *HTTPClient, httpCookie string, - handler func([]byte, float64) error, + previewDir string, + handler func(segmentURL string, b []byte, duration float64) error, ) error { var lastSeq int64 = -1 emptyRounds := 0 - const maxEmptyRounds = 60 // statt 5 + const maxEmptyRounds = 60 + + playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL)) + if err != nil || playlistBaseURL == nil { + return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL) + } + + var lastMapURL string + + writeInitIfNeeded := func(mapURL string) { + if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" { + return + } + + segDir, err := ensurePreviewSegmentsDir(previewDir) + if err != nil { + return + } + initPath := filepath.Join(segDir, "init.mp4") + + // schon vorhanden und gleiches URL-Mapping -> nichts tun + if mapURL == lastMapURL { + if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 { + return + } + } + + req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie) + if err != nil { + return + } + + resp, err := hc.client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, resp.Body) + return + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return + } + if len(b) == 0 { + return + } + + if err := os.WriteFile(initPath, b, 0o644); err != nil { + return + } + + lastMapURL = mapURL + } for { select { @@ -173,41 +346,119 @@ func (p *Playlist) WatchSegments( default: } - // Playlist holen + if strings.TrimSpace(p.PlaylistURL) == "" { + return errors.New("playlist url fehlt") + } + req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) if err != nil { - return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) + return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err) } resp, err := hc.client.Do(req) if err != nil { emptyRounds++ + //fmt.Println("playlist download failed:", p.PlaylistURL, err) + if emptyRounds >= maxEmptyRounds { return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline") } - time.Sleep(2 * time.Second) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + //fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL) + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return fmt.Errorf("❌ playlist liefert status %d – stream vermutlich offline", resp.StatusCode) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } continue } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) resp.Body.Close() - - if err != nil || listType != m3u8.MEDIA { + if err != nil { emptyRounds++ + if emptyRounds >= maxEmptyRounds { return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") } - time.Sleep(2 * time.Second) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } continue } - media := playlist.(*m3u8.MediaPlaylist) + if listType != m3u8.MEDIA { + emptyRounds++ + + if emptyRounds >= maxEmptyRounds { + return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + + media, ok := playlist.(*m3u8.MediaPlaylist) + if !ok || media == nil { + emptyRounds++ + + if emptyRounds >= maxEmptyRounds { + return errors.New("❌ Fehlerhafte Media-Playlist – möglicherweise offline") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + + if media.Map != nil { + mapURI := strings.TrimSpace(media.Map.URI) + if mapURI != "" { + mapRef, err := url.Parse(mapURI) + if err != nil { + //fmt.Println("map uri parse failed:", mapURI, err) + } else { + mapURL := playlistBaseURL.ResolveReference(mapRef).String() + writeInitIfNeeded(mapURL) + } + } + } + newSegment := false + successfulSegment := false for _, segment := range media.Segments { if segment == nil { continue } + if int64(segment.SeqId) <= lastSeq { continue } @@ -215,31 +466,66 @@ func (p *Playlist) WatchSegments( lastSeq = int64(segment.SeqId) newSegment = true - segmentURL := p.RootURL + segment.URI + segURI := strings.TrimSpace(segment.URI) + if segURI == "" { + //fmt.Println("segment uri empty in playlist:", p.PlaylistURL) + continue + } + + segRef, err := url.Parse(segURI) + if err != nil { + //fmt.Println("segment uri parse failed:", segURI, err) + continue + } + + segmentURL := playlistBaseURL.ResolveReference(segRef).String() segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) if err != nil { + //fmt.Println("segment request build failed:", segmentURL, err) continue } segResp, err := hc.client.Do(segReq) if err != nil { + //fmt.Println("segment download failed:", segmentURL, err) + continue + } + + if segResp.StatusCode < 200 || segResp.StatusCode >= 300 { + //fmt.Println("segment bad status:", segResp.StatusCode, segmentURL) + _, _ = io.Copy(io.Discard, segResp.Body) + segResp.Body.Close() continue } data, err := io.ReadAll(segResp.Body) segResp.Body.Close() - if err != nil || len(data) == 0 { + if err != nil { + //fmt.Println("segment read failed:", segmentURL, err) + continue + } + if len(data) == 0 { + //fmt.Println("segment empty:", segmentURL) continue } - if err := handler(data, segment.Duration); err != nil { + if err := handler(segmentURL, data, segment.Duration); err != nil { return err } + + successfulSegment = true } - if newSegment { + if successfulSegment { emptyRounds = 0 + } else if newSegment { + emptyRounds++ + //fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL) + + if emptyRounds >= maxEmptyRounds { + return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden – Stream vermutlich geschützt, fehlerhaft oder offline.") + } } else { emptyRounds++ if emptyRounds >= maxEmptyRounds { @@ -247,7 +533,11 @@ func (p *Playlist) WatchSegments( } } - time.Sleep(1 * time.Second) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1 * time.Second): + } } } diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 2ca87b3..6cdaa72 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -234,7 +234,7 @@ func runMFC(ctx context.Context, username string, outArg string) error { return err } if st != StatusPublic { - return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) + return fmt.Errorf("Stream ist nicht live (Status: %s)", st) } m3u8URL, err := mfc.GetVideoURL(false) @@ -379,7 +379,6 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob jobsMu.Lock() job.SizeBytes = sz jobsMu.Unlock() - publishJobUpsert(job) last = sz } } diff --git a/backend/recorder.go b/backend/recorder.go index 4dc2262..34c44ce 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -266,8 +266,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { if j.Hidden && !req.Hidden { j.Hidden = false jobsMu.Unlock() - - publishJobUpsert(j) return j, nil } @@ -317,10 +315,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { jobs[jobID] = job jobsMu.Unlock() - if !job.Hidden { - publishJobUpsert(job) - } - go runJob(ctx, job, req) return job, nil } @@ -350,7 +344,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } setJobProgress(job, "recording", 0) - publishJobUpsert(job) switch provider { case "chaturbate": @@ -383,7 +376,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() - publishJobUpsert(job) } err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) @@ -404,7 +396,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() - publishJobUpsert(job) err = RecordStreamMFC(ctx, hc, username, outPath, job) @@ -412,10 +403,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { err = errors.New("unsupported provider") } - if err != nil && shouldLogRecordError(err, provider, req) { - fmt.Println("❌ [record]", provider, job.SourceURL, "->", err) - } - end := time.Now() target := JobFinished @@ -440,7 +427,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.Phase = "postwork" out := strings.TrimSpace(job.Output) jobsMu.Unlock() - publishJobUpsert(job) if out == "" { jobsMu.Lock() @@ -452,7 +438,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() return } @@ -468,7 +453,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() if shouldLogRecordInfo(req) { if serr != nil { @@ -490,7 +474,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() - publishJobUpsert(job) s := getSettings() minMB := s.AutoDeleteSmallDownloadsBelowMB @@ -512,7 +495,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() if shouldLogRecordInfo(req) { fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")") @@ -541,7 +523,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = &s } jobsMu.Unlock() - publishJobUpsert(job) okQueued := postWorkQ.Enqueue(PostWorkTask{ Key: postKey, @@ -554,7 +535,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() setJobProgress(job, "postwork", 0) - publishJobUpsert(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -581,7 +561,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -600,7 +579,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.PostWork = &st jobsMu.Unlock() - publishJobUpsert(job) label := "Nachbearbeitung läuft…" switch strings.ToLower(strings.TrimSpace(phase)) { @@ -638,7 +616,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = out jobsMu.Unlock() - publishJobUpsert(job) } } @@ -657,7 +634,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: filepath.Base(out), @@ -686,7 +662,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = out jobsMu.Unlock() - publishJobUpsert(job) notifyDoneChanged() } @@ -698,7 +673,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.DurationSeconds = sec jobsMu.Unlock() - publishJobUpsert(job) } cancel() } @@ -715,7 +689,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.VideoHeight = h job.FPS = fps jobsMu.Unlock() - publishJobUpsert(job) } } @@ -771,7 +744,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: finalFile, @@ -803,7 +775,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.PostWork = &st jobsMu.Unlock() - publishJobUpsert(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, @@ -827,7 +798,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - notifyDoneChanged() publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: postFile, diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json new file mode 100644 index 0000000..b42d528 --- /dev/null +++ b/backend/recorder_settings.json @@ -0,0 +1,19 @@ +{ + "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", + "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", + "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", + "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", + "ffmpegPath": "", + "autoAddToDownloadList": true, + "autoStartAddedDownloads": true, + "useChaturbateApi": true, + "useMyFreeCamsWatcher": true, + "autoDeleteSmallDownloads": false, + "autoDeleteSmallDownloadsBelowMB": 50, + "lowDiskPauseBelowGB": 5, + "blurPreviews": false, + "teaserPlayback": "all", + "teaserAudio": true, + "enableNotifications": true, + "encryptedCookies": "tZzqhwM+6rrdV36edfxpkhfikSfzfs4VZESLJIpYw1U4Bwn7vo+P8Sq86M7nvXpvbQVg00QLl5Wo2dD2agojU58GYY5P5P6Wpc1ZPr2T409BaQXRA/WhHiZv7z/b4JoVYX7fScZ4FDtscQYuTdMpnU6Wr725j66Qg2x8VgXqUHb1qceUes9P3L2kc8MAvZHWnZHIEjbgDW9NAZOADlbBxOonmc5hMGpz5xzmpWWV7Gqg6n1i75U/XBDV6Ks1Jn5Ic9dwi0f1BH6zo56v5oDdPAxKPlw01TUsV9ZAA+u+TjWUiq52AJP/C20bujUEsmQupqKxMs3E4rs6DEb7SYfbQ6SSryKyYYLCv0T3+CbeUgDmgYd2n+yjatE8udHvmkCtZCWvBIK4lSglsVeFq1prgpiGBL2ItyLcR13musHGbZ7Whq/p1btJsFDTTyfGnILW+sRUcd1Y/17YM5zKAWYyi81qKop6DYqh9kBoM2y3DYVxsLkb9+lUmwN6N7oopqh/bJ8vxJKyC74=" +} diff --git a/backend/routes.go b/backend/routes.go index 9588a4a..5d918d4 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -63,6 +63,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record/unkeep", recordUnkeepVideo) api.HandleFunc("/api/record/restore", recordRestoreVideo) api.HandleFunc("/api/record/prepare-split", recordPrepareSplit) + api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets) + api.HandleFunc("/api/record/delete-many", handleDeleteMany) + api.HandleFunc("/api/record/keep-many", handleKeepMany) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/sse.go b/backend/sse.go index ea2120d..e89b90a 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -70,6 +70,15 @@ type finishedPostworkEvent struct { TS int64 `json:"ts"` } +type taskStateEvent struct { + Type string `json:"type"` // "task_state" + GenerateAssetsRunning bool `json:"generateAssetsRunning"` + RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"` + CleanupRunning bool `json:"cleanupRunning"` + AnyRunning bool `json:"anyRunning"` + TS int64 `json:"ts"` +} + func publishFinishedPostworkStateForJob( j *RecordJob, queue string, @@ -106,6 +115,24 @@ func publishFinishedPostworkStateForJob( }) } +func publishTaskState() { + assets := getGenerateAssetsTaskStatus() + regenerate := snapshotRegenerateAssetsTaskState() + cleanup := snapshotCleanupTaskState() + + ev := taskStateEvent{ + Type: "task_state", + GenerateAssetsRunning: assets.Running, + RegenerateAssetsRunning: regenerate.Running, + CleanupRunning: cleanup.Running, + AnyRunning: assets.Running || regenerate.Running || cleanup.Running, + TS: time.Now().UnixMilli(), + } + + b, _ := json.Marshal(ev) + publishSSE("taskState", b) +} + func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) { if j == nil { return @@ -125,10 +152,7 @@ func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) b, _ := json.Marshal(ev) - // 1) modellbezogen für Running/Downloads - publishSSE(eventName, b) - - // 2) global für FinishedDownloads + // nur global für FinishedDownloads publishSSE("finishedPostwork", b) } @@ -269,54 +293,6 @@ func visibleJobEventsJSON() []ssePublishItem { return out } -func publishJobUpsert(j *RecordJob) { - if j == nil || j.Hidden { - return - } - if !shouldPublishModelEventForJob(j) { - return - } - - eventName := sseModelEventNameForJob(j) - if eventName == "" { - return - } - - payload := jobEvent{ - Type: "job_upsert", - Model: eventName, - JobID: j.ID, - Status: j.Status, - Phase: j.Phase, - Progress: j.Progress, - SourceURL: j.SourceURL, - Output: j.Output, - StartedAt: j.StartedAt.Format(time.RFC3339Nano), - StartedAtMs: j.StartedAtMs, - EndedAtMs: j.EndedAtMs, - SizeBytes: j.SizeBytes, - DurationSeconds: j.DurationSeconds, - PreviewState: j.PreviewState, - PostWorkKey: strings.TrimSpace(j.PostWorkKey), - PostWork: j.PostWork, - TS: time.Now().UnixMilli(), - } - - if sm := sseStoredModelForJob(j); sm != nil { - payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus)) - payload.IsOnline = sm.IsOnline - payload.ModelImageURL = strings.TrimSpace(sm.ImageURL) - payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL) - } - - if j.EndedAt != nil { - payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano) - } - - b, _ := json.Marshal(payload) - publishSSE(eventName, b) -} - func publishJobRemove(j *RecordJob) { if j == nil || j.Hidden { return diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index f248ffc..20a9083 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -3,7 +3,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -29,6 +28,9 @@ type AssetsTaskState struct { FinishedAt *time.Time `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"` CurrentFile string `json:"currentFile,omitempty"` + CurrentQueue string `json:"currentQueue,omitempty"` + CurrentPhase string `json:"currentPhase,omitempty"` + CurrentLabel string `json:"currentLabel,omitempty"` } var assetsTaskMu sync.Mutex @@ -44,6 +46,7 @@ func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { assetsTaskMu.Unlock() notifyAssetsChanged() + publishTaskState() return st } @@ -91,12 +94,15 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { FinishedAt: nil, Error: "", CurrentFile: "", + CurrentQueue: "", + CurrentPhase: "", + CurrentLabel: "", } st := assetsTaskState assetsTaskMu.Unlock() - // ✅ SSE: Start pushen notifyAssetsChanged() + publishTaskState() go runGenerateMissingAssets(ctx) @@ -121,7 +127,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { // das macht der Worker zuverlässig im finishWithErr(context.Canceled)) st := updateAssetsState(func(st *AssetsTaskState) { if st.Running { - st.Error = "abgebrochen" + st.Error = "Abgebrochen" } }) @@ -134,7 +140,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { } } -func publishAssetsTaskFinishedPostworkState(fileName string, state string, label string) { +func publishAssetsTaskPhase(fileName string, queue string, phase string, state string, label string) { fileName = strings.TrimSpace(fileName) if fileName == "" { return @@ -143,52 +149,31 @@ func publishAssetsTaskFinishedPostworkState(fileName string, state string, label 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) + publishFinishedPostworkPhase( + fileName, + assetID, + queue, + phase, + state, + label, + ) } -func clearAssetsTaskFinishedPostworkState(fileName string) { - fileName = strings.TrimSpace(fileName) - if fileName == "" { - return +func clearAssetsTaskFinishedPostworkStates(fileName string) { + defs := []struct { + queue string + phase string + }{ + {queue: "postwork", phase: "meta"}, + {queue: "postwork", phase: "thumb"}, + {queue: "postwork", phase: "teaser"}, + {queue: "postwork", phase: "sprites"}, + {queue: "enrich", phase: "analyze"}, } - 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(), + for _, def := range defs { + publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "") } - - b, err := json.Marshal(ev) - if err != nil { - return - } - - publishSSE("finishedPostwork", b) } func runGenerateMissingAssets(ctx context.Context) { @@ -206,16 +191,17 @@ func runGenerateMissingAssets(ctx context.Context) { st.Running = false st.FinishedAt = &now st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" if err == nil { - // Erfolg: Error leeren st.Error = "" return } - // stabiler Text für UI if errors.Is(err, context.Canceled) { - st.Error = "abgebrochen" + st.Error = "Abgebrochen" } else { st.Error = err.Error() } @@ -234,77 +220,48 @@ func runGenerateMissingAssets(ctx context.Context) { path string } - // .trash niemals verarbeiten - isTrashPath := func(full string) bool { - p := strings.ToLower(strings.ReplaceAll(full, "\\", "/")) - return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") - } + indexedItems, sortedIdx := buildDoneIndex(doneAbs) + + // gleiche Grundreihenfolge wie FinishedDownloads-Default: + // includeKeep = true, sort = completed_desc + order := sortedIdx["1|completed_desc"] seen := map[string]struct{}{} - items := make([]item, 0, 512) + items := make([]item, 0, len(order)) - addIfVideo := func(full string) { - if isTrashPath(full) { - return + for _, idx := range order { + if idx < 0 || idx >= len(indexedItems) { + continue } - name := filepath.Base(full) - low := strings.ToLower(name) - if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { - return + j := indexedItems[idx].job + if j == nil { + continue } - ext := strings.ToLower(filepath.Ext(name)) - if ext != ".mp4" && ext != ".ts" { - return + + full := strings.TrimSpace(j.Output) + if full == "" { + continue } if _, ok := seen[full]; ok { - return + continue } seen[full] = struct{}{} - items = append(items, item{name: name, path: full}) + + items = append(items, item{ + name: filepath.Base(full), + path: full, + }) } - scanOneLevel := func(dir string) { - ents, err := os.ReadDir(dir) - if err != nil { - return - } - for _, e := range ents { - if e.IsDir() && strings.EqualFold(e.Name(), ".trash") { - continue - } - - full := filepath.Join(dir, e.Name()) - if e.IsDir() { - sub, err := os.ReadDir(full) - if err != nil { - continue - } - for _, se := range sub { - if se.IsDir() { - continue - } - addIfVideo(filepath.Join(full, se.Name())) - } - continue - } - addIfVideo(full) - } - } - - // done + done// + done/keep + done/keep// - scanOneLevel(doneAbs) - scanOneLevel(filepath.Join(doneAbs, "keep")) - - // ✅ Initialisierung: Total etc. + SSE Push + // Initialisierung: Total etc. + SSE Push updateAssetsState(func(st *AssetsTaskState) { st.Total = len(items) st.Done = 0 st.GeneratedThumbs = 0 st.GeneratedPreviews = 0 st.Skipped = 0 - // Start hat Error schon geleert — hier nur sicherheitshalber: st.Error = "" }) @@ -314,22 +271,16 @@ func runGenerateMissingAssets(ctx context.Context) { return } - // ✅ aktuellen Dateinamen für UI setzen + // aktuellen Dateinamen für UI setzen updateAssetsState(func(st *AssetsTaskState) { 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) + clearAssetsTaskFinishedPostworkStates(it.name) updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 @@ -340,7 +291,7 @@ 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) + clearAssetsTaskFinishedPostworkStates(it.name) updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 @@ -348,17 +299,46 @@ func runGenerateMissingAssets(ctx context.Context) { continue } + // Vorher-Wahrheit ermitteln: was fehlt wirklich? + beforeTruth := finishedPhaseTruthForID(id) + + needMeta := !beforeTruth.MetaReady + needThumb := !beforeTruth.ThumbReady + needTeaser := !beforeTruth.TeaserReady + needSprites := !beforeTruth.SpritesReady + needAnalyze := !beforeTruth.AnalyzeReady + + // Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen + if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { + clearAssetsTaskFinishedPostworkStates(it.name) + + updateAssetsState(func(st *AssetsTaskState) { + st.Skipped++ + st.Done = i + 1 + }) + continue + } + // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { - publishAssetsTaskFinishedPostworkState( - it.name, - "error", - "Assets fehlgeschlagen", - ) + if needMeta { + publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") + } + if needThumb { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } + if needTeaser { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } + if needSprites { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } + if needAnalyze { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse 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)" st.Done = i + 1 }) @@ -372,43 +352,133 @@ func runGenerateMissingAssets(ctx context.Context) { sourceURL = u } - // 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 - } + thumbGenerated := false + previewGenerated := false - if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil { + // ---------------- + // META + // ---------------- + if needMeta { updateAssetsState(func(st *AssetsTaskState) { - st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)" + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "meta" + st.CurrentLabel = "Meta" }) - //fmt.Println("⚠️ tasks generate assets analyze:", aerr) + + publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") + + okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL) + if merr != nil || !okMeta { + publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") + } else { + publishAssetsTaskPhase(it.name, "postwork", "meta", "done", "Meta") + } } - if res.Skipped { - clearAssetsTaskFinishedPostworkState(it.name) - } else { - publishAssetsTaskFinishedPostworkState( - it.name, - "done", - "Vorschau / Thumbnails fertig", - ) + // ---------------- + // THUMB + // ---------------- + if needThumb { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "thumb" + st.CurrentLabel = "Vorschaubild" + }) + + publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") + + resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) + if terr != nil { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } else if finishedPhaseTruthForID(id).ThumbReady { + if resThumb.ThumbGenerated { + thumbGenerated = true + } + publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild") + } else { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } + } + + // ---------------- + // TEASER + // ---------------- + if needTeaser { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "teaser" + st.CurrentLabel = "Teaser" + }) + + publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") + + genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL) + if terr != nil { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } else if finishedPhaseTruthForID(id).TeaserReady { + if genTeaser { + previewGenerated = true + } + publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser") + } else { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } + } + + // ---------------- + // SPRITES + // ---------------- + if needSprites { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "sprites" + st.CurrentLabel = "Sprites" + }) + + publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") + + _, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL) + if serr != nil { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } else if finishedPhaseTruthForID(id).SpritesReady { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites") + } else { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } + } + + // ---------------- + // ANALYZE + // ---------------- + if needAnalyze { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "enrich" + st.CurrentPhase = "analyze" + st.CurrentLabel = "KI-Analyse" + }) + + publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse") + + _, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw") + if aerr != nil { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + } else if finishedPhaseTruthForID(id).AnalyzeReady { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse") + } else { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + } } updateAssetsState(func(st *AssetsTaskState) { - if res.Skipped { - st.Skipped++ - } - if res.ThumbGenerated { + if thumbGenerated { st.GeneratedThumbs++ } - if res.PreviewGenerated { + if previewGenerated { st.GeneratedPreviews++ } st.Done = i + 1 diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 74e201e..fa09aca 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -14,6 +14,12 @@ import ( "time" ) +type cleanupCandidate struct { + path string + name string + size int64 +} + type cleanupResp struct { // Small downloads cleanup ScannedFiles int `json:"scannedFiles"` @@ -34,6 +40,14 @@ type cleanupReq struct { BelowMB *int `json:"belowMB,omitempty"` } +func clearCleanupTaskState() { + cleanupTaskMu.Lock() + cleanupTaskState = CleanupTaskState{} + cleanupTaskMu.Unlock() + + publishTaskState() +} + // /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 @@ -49,6 +63,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { 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 } @@ -90,6 +109,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { orphansTotalRemoved := resp.GeneratedOrphansRemoved setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved)) + time.AfterFunc(7*time.Second, func() { + clearCleanupTaskState() + }) + writeJSON(w, http.StatusOK, resp) } @@ -103,8 +126,9 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { return ext == ".mp4" || ext == ".ts" } - // scan: doneAbs + 1-level subdirs, "keep" wird übersprungen - scanDir := func(dir string, allowSubdirs bool) { + candidates := make([]cleanupCandidate, 0, 1024) + + collectCandidates := func(dir string, allowSubdirs bool) { ents, err := os.ReadDir(dir) if err != nil { return @@ -125,10 +149,12 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { if err != nil { continue } + for _, se := range sub { if se.IsDir() { continue } + name := se.Name() if !isCandidate(name) { resp.SkippedFiles++ @@ -142,31 +168,15 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { continue } - resp.ScannedFiles++ - - if fi.Size() < threshold { - base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) - id := stripHotPrefix(base) - - if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) { - resp.DeletedFiles++ - resp.DeletedBytes += fi.Size() - - // generated + legacy cleanup (best effort) - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) - } - - purgeDurationCacheForPath(p) - } else { - resp.ErrorCount++ - } - } + candidates = append(candidates, cleanupCandidate{ + path: p, + name: name, + size: fi.Size(), + }) } continue } - // root-level file name := e.Name() if !isCandidate(name) { resp.SkippedFiles++ @@ -179,29 +189,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { continue } - resp.ScannedFiles++ - - if fi.Size() < threshold { - base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)) - id := stripHotPrefix(base) - - if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) { - resp.DeletedFiles++ - resp.DeletedBytes += fi.Size() - - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) - } - - purgeDurationCacheForPath(full) - } else { - resp.ErrorCount++ - } - } + candidates = append(candidates, cleanupCandidate{ + path: full, + name: name, + size: fi.Size(), + }) } } - scanDir(doneAbs, true) + collectCandidates(doneAbs, true) + + resp.ScannedFiles = len(candidates) + + setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…") + + for i, c := range candidates { + setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name)) + + if c.size >= threshold { + continue + } + + base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) + id := stripHotPrefix(base) + + if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { + resp.DeletedFiles++ + resp.DeletedBytes += c.size + + if strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } + + purgeDurationCacheForPath(c.path) + } else { + resp.ErrorCount++ + } + } } var generatedGCRunning int32 @@ -286,6 +310,23 @@ func runGeneratedGarbageCollector() generatedGCStats { return nil }) + // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. + // Sonst räumt der GC generated/meta/ weg, obwohl RecordStream dort noch schreibt. + jobsMu.Lock() + for _, j := range jobs { + if j == nil { + continue + } + + id := strings.TrimSpace(assetIDForJob(j)) + if id == "" { + continue + } + + live[id] = struct{}{} + } + jobsMu.Unlock() + // 2) /generated/meta/ prüfen metaRoot, err := generatedMetaRoot() if err == nil { diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go new file mode 100644 index 0000000..4e9e81e --- /dev/null +++ b/backend/tasks_regenerate_assets.go @@ -0,0 +1,605 @@ +// backend\tasks_regenerate_assets.go + +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const regenerateAssetsMaxParallel = 3 + +type regenerateAssetsRequest struct { + File string `json:"file"` +} + +type regenerateAssetsPhase struct { + Queue string `json:"queue"` + Phase string `json:"phase"` + State string `json:"state"` + Label string `json:"label,omitempty"` + TS int64 `json:"ts"` +} + +type regenerateAssetsJob struct { + File string + VideoPath string + AssetID string + + Ctx context.Context + Cancel context.CancelFunc + + State string + Error string + + EnqueuedAt time.Time + StartedAt time.Time + EndedAt time.Time + + Phases map[string]regenerateAssetsPhase +} + +var ( + regenerateAssetsMu sync.Mutex + regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file + regenerateAssetsQueue []string // file names + regenerateAssetsWorkersStarted bool +) + +func publishFinishedPostworkPhase( + file string, + assetID string, + queue string, + phase string, + state string, + label string, +) { + setRegenerateAssetsJobPhase(file, queue, phase, state, label) + + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: assetID, + Queue: queue, + State: state, + Phase: phase, + Label: label, + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } +} + +type regeneratePhaseTruth struct { + MetaReady bool + ThumbReady bool + TeaserReady bool + SpritesReady bool + AnalyzeReady bool +} + +func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePhaseTruth { + videoPath = strings.TrimSpace(videoPath) + assetID = strings.TrimSpace(assetID) + + if assetID == "" && videoPath != "" { + assetID = assetIDFromVideoPath(videoPath) + } + + var out regeneratePhaseTruth + if assetID == "" { + return out + } + + _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(assetID) + if err != nil { + return out + } + + fileExists := func(p string) bool { + fi, err := os.Stat(strings.TrimSpace(p)) + return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 + } + + out.ThumbReady = fileExists(thumbPath) + out.TeaserReady = fileExists(previewPath) + out.SpritesReady = fileExists(spritePath) + + // Meta ist nur "ready", wenn die meta.json gültig zur aktuellen Videodatei passt + if videoPath != "" { + if vfi, err := os.Stat(videoPath); err == nil && vfi != nil && !vfi.IsDir() && vfi.Size() > 0 { + if meta, ok := readVideoMetaIfValid(metaPath, vfi); ok && meta != nil { + out.MetaReady = true + } + } + + // KI nur dann fertig, wenn Sprite existiert UND AI in meta.json vorhanden ist + out.AnalyzeReady = out.SpritesReady && hasAIResultsForOutput(videoPath) + } + + return out +} + +func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error { + truth := regeneratePhaseTruthForVideo(videoPath, assetID) + + if !truth.MetaReady { + return errors.New("meta.json fehlt oder ist noch unvollständig") + } + if !truth.ThumbReady { + return errors.New("preview.jpg fehlt oder ist leer") + } + if !truth.TeaserReady { + return errors.New("preview.mp4 fehlt oder ist leer") + } + return nil +} + +func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error { + truth := regeneratePhaseTruthForVideo(videoPath, assetID) + + if !truth.SpritesReady { + return errors.New("preview-sprite.jpg fehlt oder ist leer") + } + if !truth.AnalyzeReady { + return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") + } + return nil +} + +func ensureRegenerateAssetsWorkersStarted() { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + if regenerateAssetsWorkersStarted { + return + } + + regenerateAssetsWorkersStarted = true + + for i := 0; i < regenerateAssetsMaxParallel; i++ { + go regenerateAssetsWorker() + } +} + +func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + regenerateAssetsJobs[job.File] = job + regenerateAssetsQueue = append(regenerateAssetsQueue, job.File) +} + +func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + for len(regenerateAssetsQueue) > 0 { + file := regenerateAssetsQueue[0] + regenerateAssetsQueue = regenerateAssetsQueue[1:] + + job := regenerateAssetsJobs[file] + if job == nil { + continue + } + if job.State != "queued" { + continue + } + + job.State = "running" + job.StartedAt = time.Now() + return job + } + + return nil +} + +func finishRegenerateAssetsJob(file string, state string, errMsg string) { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + job := regenerateAssetsJobs[file] + if job == nil { + return + } + + job.State = state + job.Error = strings.TrimSpace(errMsg) + job.EndedAt = time.Now() +} + +func setRegenerateAssetsJobPhase( + file string, + queue string, + phase string, + state string, + label string, +) { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + job := regenerateAssetsJobs[file] + if job == nil { + return + } + if job.Phases == nil { + job.Phases = make(map[string]regenerateAssetsPhase) + } + + key := strings.ToLower(strings.TrimSpace(queue)) + ":" + strings.ToLower(strings.TrimSpace(phase)) + job.Phases[key] = regenerateAssetsPhase{ + Queue: strings.TrimSpace(queue), + Phase: strings.TrimSpace(phase), + State: strings.TrimSpace(state), + Label: strings.TrimSpace(label), + TS: time.Now().UnixMilli(), + } +} + +func removeRegenerateAssetsJobLater(file string, after time.Duration) { + time.AfterFunc(after, func() { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + delete(regenerateAssetsJobs, file) + }) +} + +func getRegenerateAssetsJobsSnapshot() []map[string]any { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + out := make([]map[string]any, 0, len(regenerateAssetsJobs)) + for _, job := range regenerateAssetsJobs { + phases := make([]map[string]any, 0, len(job.Phases)) + for _, p := range job.Phases { + phases = append(phases, map[string]any{ + "queue": p.Queue, + "phase": p.Phase, + "state": p.State, + "label": p.Label, + "ts": p.TS, + }) + } + + out = append(out, map[string]any{ + "file": job.File, + "assetId": job.AssetID, + "state": job.State, + "error": job.Error, + "phases": phases, + "enqueuedAt": job.EnqueuedAt.UnixMilli(), + "startedAt": func() int64 { + if job.StartedAt.IsZero() { + return 0 + } + return job.StartedAt.UnixMilli() + }(), + "endedAt": func() int64 { + if job.EndedAt.IsZero() { + return 0 + } + return job.EndedAt.UnixMilli() + }(), + }) + } + + return out +} + +func regenerateAssetsWorker() { + for { + job := nextQueuedRegenerateAssetsJob() + if job == nil { + time.Sleep(250 * time.Millisecond) + continue + } + + runRegenerateAssetsJob(job) + } +} + +func runRegenerateAssetsJob(job *regenerateAssetsJob) { + file := job.File + videoPath := job.VideoPath + id := job.AssetID + ctx := job.Ctx + + setRegenerateAssetsTaskRunning(file, file) + cancelDeferredEnrichForFile(file) + + cancelJob := func() { + finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(file, "Abgebrochen") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + removeRegenerateAssetsJobLater(file, 2*time.Second) + } + + failJob := func(queue, phase, msg string) { + if queue != "" && phase != "" { + publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler") + } + + finishRegenerateAssetsJob(file, "error", msg) + setRegenerateAssetsTaskError(file, msg) + removeRegenerateAssetsJobLater(file, 5*time.Second) + } + + if ctx.Err() == context.Canceled { + cancelJob() + return + } + + // Initialstatus + publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") + + // Alte Assets löschen + if id != "" { + if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { + _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) + _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) + _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) + _ = os.Remove(metaPath) + } + } + + // 1) Meta + if _, err := ensureMetaForVideoCtx(ctx, videoPath, ""); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + cancelJob() + return + } + failJob("postwork", "meta", err.Error()) + return + } + + if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady { + failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig") + return + } + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") + + // 2) Thumb + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") + + if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + cancelJob() + return + } + failJob("postwork", "thumb", err.Error()) + return + } + + if !regeneratePhaseTruthForVideo(videoPath, id).ThumbReady { + failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer") + return + } + + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") + + // 3) Teaser + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") + + if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + cancelJob() + return + } + failJob("postwork", "teaser", err.Error()) + return + } + + if !regeneratePhaseTruthForVideo(videoPath, id).TeaserReady { + failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer") + return + } + + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") + + // 4) Sprites + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") + + if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + cancelJob() + return + } + failJob("postwork", "sprites", err.Error()) + return + } + + if !regeneratePhaseTruthForVideo(videoPath, id).SpritesReady { + failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer") + return + } + + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") + + // 5) KI-Analyse + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") + + if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + cancelJob() + return + } + failJob("enrich", "analyze", err.Error()) + return + } + + if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady { + failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") + return + } + + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") + + finishRegenerateAssetsJob(file, "done", "") + setRegenerateAssetsTaskDone(file, "Fertig") + removeRegenerateAssetsJobLater(file, 3*time.Second) +} + +func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if r.Method == http.MethodDelete { + var req regenerateAssetsRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + file := strings.TrimSpace(req.File) + + if file == "" { + regenerateAssetsMu.Lock() + for _, j := range regenerateAssetsJobs { + if j != nil && (j.State == "running" || j.State == "queued") { + file = j.File + break + } + } + regenerateAssetsMu.Unlock() + } + + if file == "" { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + + regenerateAssetsMu.Lock() + job := regenerateAssetsJobs[file] + regenerateAssetsMu.Unlock() + + if job != nil && job.Cancel != nil { + job.Cancel() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "cancelled": true, + "file": file, + }) + return + } + + var req regenerateAssetsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + file := strings.TrimSpace(req.File) + if file == "" { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + if !isAllowedVideoExt(file) { + http.Error(w, "nicht erlaubt", http.StatusForbidden) + return + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil { + http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + if strings.TrimSpace(doneAbs) == "" { + http.Error(w, "doneDir ist leer", http.StatusBadRequest) + return + } + + videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + if fi == nil || fi.IsDir() || fi.Size() <= 0 { + http.Error(w, "invalid output path", http.StatusBadRequest) + return + } + + ensureRegenerateAssetsWorkersStarted() + + regenerateAssetsMu.Lock() + if existing := regenerateAssetsJobs[file]; existing != nil { + regenerateAssetsMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + phases := make([]map[string]any, 0, len(existing.Phases)) + for _, p := range existing.Phases { + phases = append(phases, map[string]any{ + "queue": p.Queue, + "phase": p.Phase, + "state": p.State, + "label": p.Label, + "ts": p.TS, + }) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "file": file, + "state": existing.State, + "error": existing.Error, + "phases": phases, + }) + return + } + regenerateAssetsMu.Unlock() + + runCtx, runCancel := context.WithCancel(context.Background()) + + id := assetIDFromVideoPath(videoPath) + if strings.TrimSpace(id) == "" { + id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + } + + job := ®enerateAssetsJob{ + File: file, + VideoPath: videoPath, + AssetID: id, + Ctx: runCtx, + Cancel: runCancel, + State: "queued", + EnqueuedAt: time.Now(), + Phases: make(map[string]regenerateAssetsPhase), + } + + enqueueRegenerateAssetsJob(job) + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "file": file, + "state": "queued", + }) +} diff --git a/backend/tasks_status.go b/backend/tasks_status.go index e4aefd6..633026e 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -7,25 +7,112 @@ import ( "net/http" "strings" "sync" + "time" ) type CleanupTaskState struct { + Running bool `json:"running"` + Done int `json:"done"` + Total int `json:"total"` + Text string `json:"text,omitempty"` + CurrentFile string `json:"currentFile,omitempty"` + Error string `json:"error,omitempty"` + FinishedAt *string `json:"finishedAt,omitempty"` +} + +type RegenerateAssetsTaskState struct { Running bool `json:"running"` + File string `json:"file,omitempty"` Text string `json:"text,omitempty"` Error string `json:"error,omitempty"` } type TasksStatusResponse struct { - GenerateAssets AssetsTaskState `json:"generateAssets"` - Cleanup CleanupTaskState `json:"cleanup"` - AnyRunning bool `json:"anyRunning"` + GenerateAssets AssetsTaskState `json:"generateAssets"` + RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` + RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` + Cleanup CleanupTaskState `json:"cleanup"` + AnyRunning bool `json:"anyRunning"` } var ( cleanupTaskMu sync.Mutex cleanupTaskState CleanupTaskState + + regenerateAssetsTaskMu sync.Mutex + regenerateAssetsTaskState RegenerateAssetsTaskState ) +func cleanupNowISO() *string { + s := time.Now().Format(time.RFC3339) + return &s +} + +func anyToString(v any) string { + switch x := v.(type) { + case string: + return x + case []byte: + return string(x) + default: + return "" + } +} + +func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { + regenerateAssetsTaskMu.Lock() + st := regenerateAssetsTaskState + regenerateAssetsTaskMu.Unlock() + return st +} + +func setRegenerateAssetsTaskRunning(file, text string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: true, + File: strings.TrimSpace(file), + Text: strings.TrimSpace(text), + Error: "", + } + regenerateAssetsTaskMu.Unlock() + + publishTaskState() +} + +func setRegenerateAssetsTaskDone(file, text string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: false, + File: strings.TrimSpace(file), + Text: strings.TrimSpace(text), + Error: "", + } + regenerateAssetsTaskMu.Unlock() + + publishTaskState() +} + +func setRegenerateAssetsTaskError(file, errText string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: false, + File: strings.TrimSpace(file), + Text: "", + Error: strings.TrimSpace(errText), + } + regenerateAssetsTaskMu.Unlock() + + publishTaskState() +} + +func clearRegenerateAssetsTaskState() { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{} + regenerateAssetsTaskMu.Unlock() + + publishTaskState() +} + func snapshotCleanupTaskState() CleanupTaskState { cleanupTaskMu.Lock() st := cleanupTaskState @@ -36,31 +123,60 @@ func snapshotCleanupTaskState() CleanupTaskState { func setCleanupTaskRunning(text string) { cleanupTaskMu.Lock() cleanupTaskState = CleanupTaskState{ - Running: true, - Text: strings.TrimSpace(text), - Error: "", + Running: true, + Done: 0, + Total: 0, + Text: strings.TrimSpace(text), + CurrentFile: "", + Error: "", + FinishedAt: nil, } cleanupTaskMu.Unlock() + + publishTaskState() +} + +func setCleanupTaskProgress(done, total int, currentFile, text string) { + cleanupTaskMu.Lock() + cleanupTaskState.Running = true + cleanupTaskState.Done = done + cleanupTaskState.Total = total + cleanupTaskState.CurrentFile = strings.TrimSpace(currentFile) + cleanupTaskState.Text = strings.TrimSpace(text) + cleanupTaskState.Error = "" + cleanupTaskState.FinishedAt = nil + cleanupTaskMu.Unlock() + + publishTaskState() } func setCleanupTaskDone(text string) { cleanupTaskMu.Lock() - cleanupTaskState = CleanupTaskState{ - Running: false, - Text: strings.TrimSpace(text), - Error: "", + cleanupTaskState.Running = false + cleanupTaskState.Text = strings.TrimSpace(text) + cleanupTaskState.CurrentFile = "" + cleanupTaskState.Error = "" + cleanupTaskState.FinishedAt = cleanupNowISO() + + if cleanupTaskState.Total > 0 { + cleanupTaskState.Done = cleanupTaskState.Total } + cleanupTaskMu.Unlock() + + publishTaskState() } func setCleanupTaskError(errText string) { cleanupTaskMu.Lock() - cleanupTaskState = CleanupTaskState{ - Running: false, - Text: "", - Error: strings.TrimSpace(errText), - } + cleanupTaskState.Running = false + cleanupTaskState.Text = "" + cleanupTaskState.Error = strings.TrimSpace(errText) + cleanupTaskState.CurrentFile = "" + cleanupTaskState.FinishedAt = cleanupNowISO() cleanupTaskMu.Unlock() + + publishTaskState() } func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { @@ -70,12 +186,27 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { } assets := getGenerateAssetsTaskStatus() + regenerate := snapshotRegenerateAssetsTaskState() + regenerateJobs := getRegenerateAssetsJobsSnapshot() cleanup := snapshotCleanupTaskState() + anyRegenerateRunning := regenerate.Running + if !anyRegenerateRunning { + for _, job := range regenerateJobs { + state := strings.TrimSpace(strings.ToLower(anyToString(job["state"]))) + if state == "queued" || state == "running" { + anyRegenerateRunning = true + break + } + } + } + resp := TasksStatusResponse{ - GenerateAssets: assets, - Cleanup: cleanup, - AnyRunning: assets.Running || cleanup.Running, + GenerateAssets: assets, + RegenerateAssets: regenerate, + RegenerateAssetsJobs: regenerateJobs, + Cleanup: cleanup, + AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running, } w.Header().Set("Content-Type", "application/json") diff --git a/frontend/package.json b/frontend/package.json index c6425d5..fb69e25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f4010f..4e1da91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,10 +65,11 @@ type RecorderSettingsState = { teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean lowDiskPauseBelowGB?: number + enableNotifications?: boolean } type JobEvent = { - type?: 'job_upsert' | 'job_remove' | 'finished_postwork' + type?: 'job_upsert' | 'job_remove' model?: string jobId?: string status?: string @@ -110,6 +111,15 @@ type JobEvent = { maxParallel?: number } +type TaskStateEvent = { + type?: 'task_state' + generateAssetsRunning?: boolean + regenerateAssetsRunning?: boolean + cleanupRunning?: boolean + anyRunning?: boolean + ts?: number +} + const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', @@ -124,6 +134,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { teaserPlayback: 'hover', teaserAudio: false, lowDiskPauseBelowGB: 5, + enableNotifications: true, } type StoredModel = { @@ -203,6 +214,27 @@ function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'aw } } +function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { + return normalizePendingShow(v) +} + +function roomStatusLabelDE(v: unknown): string { + switch (normalizeRoomStatusText(v)) { + case 'public': + return 'live' + case 'private': + return 'privat' + case 'hidden': + return 'hidden' + case 'away': + return 'away' + case 'offline': + return 'offline' + default: + return 'unbekannt' + } +} + function normalizeHttpUrl(raw: string): string | null { let v = (raw ?? '').trim() if (!v) return null @@ -450,6 +482,9 @@ export default function App() { const notifyRef = useRef(notify) + const lastKnownRoomStatusByKeyRef = useRef>({}) + const roomStatusBaselineReadyRef = useRef(false) + // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen) const cookieProblemLastAtRef = useRef(0) @@ -507,6 +542,22 @@ export default function App() { const eventSourceRef = useRef(null) const modelEventNamesRef = useRef>(new Set()) const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) + + + const [playerModelKey, setPlayerModelKey] = useState(null) + const [sourceUrl, setSourceUrl] = useState('') + const [jobs, setJobs] = useState([]) + const [doneJobs, setDoneJobs] = useState([]) + const [donePage, setDonePage] = useState(1) + const [doneCount, setDoneCount] = useState(0) + const [modelsCount, setModelsCount] = useState(0) + + const [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) + + const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const [modelsByKey, setModelsByKey] = useState>({}) const selectSourceUrl = useCallback(() => { const el = sourceUrlInputRef.current @@ -570,56 +621,6 @@ export default function App() { } }, []) - useEffect(() => { - if (!authed) return - - let cancelled = false - let timer: number | null = null - - const loadSettingsTaskState = async () => { - try { - const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }) - if (!res.ok) { - if (!cancelled) setSettingsTaskRunning(false) - return - } - - const data = await res.json().catch(() => null) - if (cancelled) return - - const running = Boolean(data?.running) - setSettingsTaskRunning(running) - } catch { - if (!cancelled) setSettingsTaskRunning(false) - } - } - - const schedule = (ms: number) => { - if (cancelled) return - timer = window.setTimeout(async () => { - await loadSettingsTaskState() - schedule(document.hidden ? 8000 : 3000) - }, ms) - } - - void loadSettingsTaskState() - schedule(3000) - - const onVis = () => { - if (!document.hidden) { - void loadSettingsTaskState() - } - } - - document.addEventListener('visibilitychange', onVis) - - return () => { - cancelled = true - if (timer != null) window.clearTimeout(timer) - document.removeEventListener('visibilitychange', onVis) - } - }, [authed]) - useEffect(() => { void checkAuth() }, [checkAuth]) @@ -768,6 +769,43 @@ export default function App() { } }, []) + const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => { + try { + const key = makePrefetchKey(pageToLoad, sortToLoad) + const prefetched = donePrefetchRef.current + + if (prefetched?.key === key && Array.isArray(prefetched.items)) { + setDoneJobs(prefetched.items) + setLastHeaderUpdateAtMs(Date.now()) + + donePrefetchRef.current = null + void prefetchDonePage(pageToLoad + 1) + return + } + + const res = await fetch( + `/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`, + { cache: 'no-store' as any } + ) + if (!res.ok) return + + const data = await res.json().catch(() => null) + + const items = Array.isArray(data?.items) + ? (data.items as RecordJob[]) + : Array.isArray(data) + ? (data as RecordJob[]) + : [] + + setDoneJobs(items) + setLastHeaderUpdateAtMs(Date.now()) + + void prefetchDonePage(pageToLoad + 1) + } catch { + // ignore + } + }, [donePage, doneSort, prefetchDonePage]) + const requestFinishedReload = useCallback((reason = 'unknown') => { const now = Date.now() const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme @@ -853,20 +891,14 @@ export default function App() { void loadDoneCount() }, [authed, loadDoneCount]) - const [playerModelKey, setPlayerModelKey] = useState(null) - const [sourceUrl, setSourceUrl] = useState('') - const [jobs, setJobs] = useState([]) - const [doneJobs, setDoneJobs] = useState([]) - const [donePage, setDonePage] = useState(1) - const [doneCount, setDoneCount] = useState(0) - const [modelsCount, setModelsCount] = useState(0) + useEffect(() => { + if (!authed) return + void loadDonePage(donePage, doneSort) + }, [authed, donePage, doneSort, loadDonePage]) - const [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) - - const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) - const [nowMs, setNowMs] = useState(() => Date.now()) - - const [modelsByKey, setModelsByKey] = useState>({}) + useEffect(() => { + setModelsCount(Object.keys(modelsByKey).length) + }, [modelsByKey]) const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const h = String(m?.host ?? '').toLowerCase() @@ -1124,13 +1156,76 @@ export default function App() { const refreshModelsByKey = useCallback(async () => { try { const list = await apiJSON('/api/models', { cache: 'no-store' as any }) - setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : [])) + const safeList = Array.isArray(list) ? list : [] + + setModelsByKey(buildModelsByKey(safeList)) setLastHeaderUpdateAtMs(Date.now()) } catch { // ignore } }, [buildModelsByKey]) + const maybeNotifyWatchedModelStatusChange = useCallback(( + keyLower: string, + nextStatusRaw: unknown, + patch?: { + imageUrl?: string + } + ) => { + if (!keyLower) return + if (!recSettingsRef.current.enableNotifications) return + if (!roomStatusBaselineReadyRef.current) return + + const model = modelsByKeyRef.current[keyLower] + if (!model?.watching) { + lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw) + return + } + + const nextStatus = normalizeRoomStatusText(nextStatusRaw) + const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) + + // keine Änderung + if (prevStatus === nextStatus) return + + // keine "erste Sichtung" als Wechsel melden + const hadPreviousRealStatus = + Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) && + prevStatus !== 'unknown' + + lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus + + if (!hadPreviousRealStatus) return + + const displayName = + String(model?.modelKey ?? keyLower).trim() || keyLower + + const imageUrl = + String(patch?.imageUrl ?? '').trim() || + String(model?.imageUrl ?? '').trim() || + undefined + + const title = + nextStatus === 'public' + ? `${displayName} ist live` + : `${displayName} Status geändert` + + const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}` + + notifyRef.current?.info(title, message, { + imageUrl, + imageAlt: displayName, + durationMs: 5000, + onClick: () => { + window.dispatchEvent( + new CustomEvent('open-model-details', { + detail: { modelKey: keyLower }, + }) + ) + }, + }) + }, []) + const enqueueStart = useCallback( (item: StartQueueItem) => { const norm0 = normalizeHttpUrl(item.url) @@ -1163,56 +1258,6 @@ 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() @@ -1223,6 +1268,10 @@ export default function App() { ? Boolean(msg.isOnline) : roomStatus !== 'offline' + maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, { + imageUrl: String(msg?.modelImageUrl ?? '').trim(), + }) + applyRoomStateToModel(modelKey, { roomStatus, isOnline, @@ -1247,7 +1296,7 @@ export default function App() { // ignore } } - }, [applyJobDelta, applyRoomStateToModel, enqueueStart]) + }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange]) useEffect(() => { const t = window.setInterval(() => setNowMs(Date.now()), 1000) @@ -1354,7 +1403,9 @@ export default function App() { // ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models) if (updated && typeof updated === 'object') { const k = String(updated.modelKey ?? '').toLowerCase().trim() - if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated })) + if (k) { + setModelsByKey((prev) => ({ ...prev, [k]: updated })) + } try { upsertModelCache(updated) @@ -1614,6 +1665,24 @@ export default function App() { modelsByKeyRef.current = modelsByKey }, [modelsByKey]) + useEffect(() => { + const next: Record = {} + + for (const [rawKey, model] of Object.entries(modelsByKey || {})) { + const key = String(rawKey ?? '').trim().toLowerCase() + if (!key) continue + + const status = normalizeRoomStatusText((model as any)?.roomStatus) + next[key] = status + } + + lastKnownRoomStatusByKeyRef.current = next + + if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) { + roomStatusBaselineReadyRef.current = true + } + }, [modelsByKey]) + const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey) useEffect(() => { pendingAutoStartByKeyRef.current = pendingAutoStartByKey @@ -1707,6 +1776,10 @@ export default function App() { for (const key of keys) { const snap = byKey[key] ?? { show: 'offline' as const } + maybeNotifyWatchedModelStatusChange(key, snap.show, { + imageUrl: snap.imageUrl, + }) + applyPendingRoomSnapshot(key, snap) if (snap.show === 'public') { @@ -2094,31 +2167,6 @@ export default function App() { } }, []) - // ✅ Models-Count (leicht) - useEffect(() => { - let cancelled = false - - const load = async () => { - try { - const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' }) - const c = Number(meta?.count ?? 0) - if (!cancelled && Number.isFinite(c)) { - setModelsCount(c) - setLastHeaderUpdateAtMs(Date.now()) - } - } catch { - // ignore - } - } - - load() - const t = window.setInterval(load, document.hidden ? 60000 : 30000) - return () => { - cancelled = true - window.clearInterval(t) - } - }, []) - const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies]) const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => { @@ -2333,6 +2381,7 @@ export default function App() { void loadDoneCount() void loadJobs() + void loadTaskStateOnce() if (selectedTabRef.current === 'finished') { requestFinishedReload('sse fallback poll') @@ -2340,31 +2389,23 @@ export default function App() { }, document.hidden ? 60000 : 5000) } - const lastDoneFireRef = { t: 0 } - let doneCoalesceTimer: number | null = null + const loadTaskStateOnce = async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' as any }) + if (!res.ok) return - const requestDoneRefresh = () => { - const now = Date.now() - const since = now - lastDoneFireRef.t + const data = await res.json().catch(() => null) - if (since < 800) { - if (doneCoalesceTimer != null) return - doneCoalesceTimer = window.setTimeout(() => { - doneCoalesceTimer = null - lastDoneFireRef.t = Date.now() - - void loadDoneCount() - if (selectedTabRef.current === 'finished') { - requestFinishedReload('doneChanged coalesced') - } - }, 900) - return - } - - lastDoneFireRef.t = now - void loadDoneCount() - if (selectedTabRef.current === 'finished') { - requestFinishedReload('doneChanged') + setSettingsTaskRunning( + Boolean( + data?.anyRunning || + data?.generateAssets?.running || + data?.regenerateAssets?.running || + data?.cleanup?.running + ) + ) + } catch { + // ignore } } @@ -2377,11 +2418,79 @@ export default function App() { }) } - // Initialdaten + const onDoneChanged = () => { + void loadDoneCount() + } + + const onAutostart = (ev: MessageEvent) => { + try { + applyAutostartState(JSON.parse(String(ev.data ?? 'null'))) + } catch {} + } + + const onFinishedPostwork = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent + const file = String(msg?.file ?? '').trim() + if (!file) return + + const state = + msg?.state === 'queued' || + msg?.state === 'running' || + msg?.state === 'done' || + msg?.state === 'error' || + msg?.state === 'missing' + ? msg.state + : 'missing' + + window.dispatchEvent( + new CustomEvent('finished-downloads:postwork', { + detail: { + file, + assetId: String(msg?.assetId ?? '').trim() || undefined, + queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork', + state, + 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(), + }, + }) + ) + + // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen + if (state === 'done' || state === 'error' || state === 'missing') { + bumpAssets() + } + + // ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen + if (selectedTabRef.current === 'finished') { + requestFinishedReload(`finishedPostwork:${state}`) + } + } catch {} + } + + const onTaskState = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent + setSettingsTaskRunning( + Boolean( + msg?.anyRunning || + msg?.generateAssetsRunning || + msg?.regenerateAssetsRunning || + msg?.cleanupRunning + ) + ) + } catch {} + } + void loadJobs() void loadDoneCount() + void loadTaskStateOnce() - // Optional initialer Fallback für autostart, bis erstes SSE kommt void apiJSON('/api/autostart/state', { cache: 'no-store' as any }) .then((s) => { setAutostartState({ @@ -2398,112 +2507,24 @@ export default function App() { es.onopen = () => { stopFallbackPoll() + void loadTaskStateOnce() } es.onerror = () => { startFallbackPoll() } - const onDoneChanged = () => { - requestDoneRefresh() - } - - const onAutostart = (ev: MessageEvent) => { - try { - applyAutostartState(JSON.parse(String(ev.data ?? 'null'))) - } catch { - // 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 - - // 1) sichtbare Downloads + sichtbare Nacharbeiten - for (const j of jobsRef.current) { - if (!isVisibleDownloadJobForSSE(j) && !isVisiblePostworkJobForSSE(j)) continue - - const key = modelEventKeyFromJob(j) - if (key) ensureModelEventListener(key) - } - - // 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) - } + es.addEventListener('taskState', onTaskState as any) const onVis = () => { if (document.hidden) return void loadJobs() void loadDoneCount() + void loadTaskStateOnce() + if (selectedTabRef.current === 'finished') { requestFinishedReload('visibilitychange') } @@ -2513,17 +2534,13 @@ export default function App() { return () => { document.removeEventListener('visibilitychange', onVis) - - if (doneCoalesceTimer != null) { - window.clearTimeout(doneCoalesceTimer) - } - stopFallbackPoll() if (es) { es.removeEventListener('doneChanged', onDoneChanged as any) es.removeEventListener('autostart', onAutostart as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any) + es.removeEventListener('taskState', onTaskState as any) const handler = onModelJobEventRef.current if (handler) { @@ -2537,18 +2554,8 @@ export default function App() { eventSourceRef.current = null modelEventNamesRef.current = new Set() - es = null } - }, [ - authed, - loadJobs, - loadDoneCount, - requestFinishedReload, - ensureModelEventListener, - doneJobs, - pendingWatchedRooms, - watchedModelKeysLower, - ]) + }, [authed, loadJobs, loadDoneCount, requestFinishedReload]) useEffect(() => { const desired = new Set() @@ -2853,6 +2860,10 @@ export default function App() { // ✅ Prefetch wieder nachfüllen void prefetchDonePage(donePage + 1) + + // ✅ wichtig + requestFinishedReload('delete success') + void loadDoneCount() }, 320) }, onError: async () => { @@ -2881,16 +2892,48 @@ export default function App() { run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), onSuccess: async () => { window.setTimeout(() => { - // ✅ Entfernt aus Finished + Running + Player - setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) + // ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch) + setDoneJobs((prev) => { + const filtered = prev.filter((j) => baseName(j.output || '') !== file) + + const need = DONE_PAGE_SIZE - filtered.length + if (need <= 0) return filtered + + const prefetchKey = makePrefetchKey(donePage + 1, doneSort) + const buf = donePrefetchRef.current + + if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) { + return filtered + } + + const next: RecordJob[] = [...filtered] + const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim())) + + while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) { + const cand = buf.items.shift()! + const id = String(cand.id || baseName(cand.output || '')).trim() + if (!id || used.has(id)) continue + used.add(id) + next.push(cand) + } + + donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts } + return next + }) + + // ✅ Entfernt aus Running + Player setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) - // ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished") + // ✅ Count runter setDoneCount((c) => Math.max(0, c - 1)) - // Optional: nächste Seite vorladen (schadet nicht) + // ✅ Prefetch wieder nachfüllen void prefetchDonePage(donePage + 1) + + // ✅ Finished-Liste aktiv neu laden + requestFinishedReload('keep success') + void loadDoneCount() }, 320) }, onError: async () => { @@ -2901,7 +2944,15 @@ export default function App() { return } }, - [runFinishedFileAction, notify, donePage, prefetchDonePage] + [ + runFinishedFileAction, + notify, + donePage, + doneSort, + prefetchDonePage, + requestFinishedReload, + loadDoneCount, + ] ) const handleToggleHot = useCallback( @@ -2957,7 +3008,7 @@ export default function App() { return res } catch (e: any) { - notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) + notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output)) return } }, @@ -3322,7 +3373,7 @@ export default function App() {
-
+
@@ -3476,7 +3527,7 @@ export default function App() {
-
+
@@ -3535,6 +3586,7 @@ export default function App() { blurPreviews={Boolean(recSettings.blurPreviews)} teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserAudio={Boolean(recSettings.teaserAudio)} + pauseTeasers={Boolean(detailsModelKey) || splitModalOpen} assetNonce={assetNonce} sortMode={doneSort} onSortModeChange={(m) => { @@ -3552,7 +3604,6 @@ export default function App() { {selectedTab === 'settings' ? ( ) : null} diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..1dfe96d --- /dev/null +++ b/frontend/src/components/ui/Checkbox.tsx @@ -0,0 +1,71 @@ +// frontend\src\components\ui\Checkbox.tsx + +'use client' + +import * as React from 'react' + +type Props = { + checked: boolean + indeterminate?: boolean + disabled?: boolean + onChange: (checked: boolean) => void + ariaLabel?: string + id?: string + name?: string + className?: string +} + +export default function Checkbox({ + checked, + indeterminate = false, + disabled = false, + onChange, + ariaLabel, + id, + name, + className = '', +}: Props) { + const ref = React.useRef(null) + + React.useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate && !checked + } + }, [indeterminate, checked]) + + return ( +
+ onChange(e.target.checked)} + className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-900 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto" + /> + + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/CookieModal.tsx b/frontend/src/components/ui/CookieModal.tsx index 2a7a3ae..258f6fa 100644 --- a/frontend/src/components/ui/CookieModal.tsx +++ b/frontend/src/components/ui/CookieModal.tsx @@ -59,7 +59,7 @@ export default function CookieModal({ } return ( - +