diff --git a/Icons/toy.png b/Icons/toy.png index 919d20d..7970077 100644 Binary files a/Icons/toy.png and b/Icons/toy.png differ diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..c1ed77b --- /dev/null +++ b/backend/.env @@ -0,0 +1,2 @@ +HTTPS_ENABLED=0 +AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/analyze.go b/backend/analyze.go index 1a8b0d7..75d1bfe 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -9,11 +9,13 @@ import ( "fmt" "image" "image/jpeg" + "io" "math" "net/http" "os" "os/exec" "path/filepath" + "runtime/debug" "sort" "strings" "sync" @@ -24,8 +26,8 @@ import ( type analyzeVideoReq struct { JobID string `json:"jobId"` Output string `json:"output"` - Mode string `json:"mode"` // "video" | "sprite" - Goal string `json:"goal"` // "highlights" | "nsfw" + Mode string `json:"mode"` // "video" + Goal string `json:"goal"` // "highlights" } type analyzeHit struct { @@ -59,18 +61,16 @@ const ( // Bei 3s Frame-Intervall heißt das: ein fehlender Frame wird überbrückt. analyzeLabelInvisibleGraceSeconds = 3.0 - nsfwThresholdModerate = 0.35 - nsfwThresholdStrong = 0.60 - // Video-Modus: extrahiert 1 Frame alle N Sekunden. // 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden. - analyzeVideoFrameIntervalSeconds = 3 + analyzeVideoFrameIntervalSeconds = 2 // AI-Server nicht mit tausenden Pfaden auf einmal fluten. analyzeFramePredictBatchSize = 32 - // 640 ist für YOLO meist deutlich schneller als 960/1280. - analyzeVideoFrameWidth = 640 + // > 0 = Frames auf diese Breite skalieren. + // <= 0 = Originalgröße des Videos behalten. + analyzeVideoFrameWidth = 0 // Lokaler optionaler Python-Inference-Server. // Kann per Environment überschrieben werden: @@ -78,6 +78,51 @@ const ( analyzeAIServerDefaultURL = "http://127.0.0.1:8765" ) +const ( + analyzeMinPositionScore = 0.30 + analyzeMinBodyScore = 0.25 + analyzeMinObjectScore = 0.28 + analyzeMinClothingScore = 0.40 + analyzeMinDetectorScore = 0.35 + + analyzeMinSingleSeverity = 0.60 + analyzeMinSingleScore = 0.25 + analyzeMinSingleQuality = 0.24 + + analyzeMinComboScore = 0.36 +) + +func analyzeVideoFrameFilter(intervalSeconds int) string { + if intervalSeconds <= 0 { + intervalSeconds = 1 + } + + fps := fmt.Sprintf("fps=1/%d", intervalSeconds) + + // Originalgröße behalten. + if analyzeVideoFrameWidth <= 0 { + return fps + } + + return fmt.Sprintf( + "%s,scale=%d:-2:flags=fast_bilinear", + fps, + analyzeVideoFrameWidth, + ) +} + +func analyzeSingleFrameFilter() string { + // Originalgröße behalten. + if analyzeVideoFrameWidth <= 0 { + return "" + } + + return fmt.Sprintf( + "scale=%d:-2:flags=fast_bilinear", + analyzeVideoFrameWidth, + ) +} + func autoSelectedAILabelSet() map[string]struct{} { grouped, err := trainingGroupedLabels() if err != nil { @@ -131,73 +176,6 @@ func isIgnoredNSFWLabel(label string) bool { return ok } -func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) { - label = strings.ToLower(strings.TrimSpace(label)) - if label == "" { - return - } - - if score <= 0 { - score = 1 - } - - if old, ok := best[label]; !ok || score > old { - best[label] = score - } -} - -func trainingPredictionToNSFWResults(pred TrainingPrediction) []NsfwFrameResult { - best := map[string]float64{} - - // Für NSFW/AI-Segmente nur echte Boxen verwenden. - // BodyPartsPresent/ObjectsPresent/ClothingPresent sind daraus abgeleitete Übersichten - // und können sonst Labels doppelt oder zu breit einbringen. - for _, box := range pred.Boxes { - label := strings.ToLower(strings.TrimSpace(box.Label)) - if label == "" { - continue - } - - if isIgnoredNSFWLabel(label) { - continue - } - - // Nur Labels zulassen, die für Analyse/Rating relevant sind. - // Dadurch erzeugen neue YOLO-Klassen nur dann Segmente, - // wenn du sie bewusst in autoSelectedAILabels einträgst. - if !shouldAutoSelectAnalyzeHit(label) { - continue - } - - score := box.Score - if score <= 0 { - score = 1 - } - - if old, ok := best[label]; !ok || score > old { - best[label] = score - } - } - - out := make([]NsfwFrameResult, 0, len(best)) - - for label, score := range best { - out = append(out, NsfwFrameResult{ - Label: label, - Score: score, - }) - } - - sort.Slice(out, func(i, j int) bool { - if out[i].Score == out[j].Score { - return out[i].Label < out[j].Label - } - return out[i].Score > out[j].Score - }) - - return out -} - func addHighlightResult(best map[string]float64, label string, score float64) { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || label == "unknown" { @@ -233,7 +211,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe best := map[string]float64{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" { + if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) } @@ -262,7 +240,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe } // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. - if sexPosition != "" && sexPosition != "unknown" { + if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 1 @@ -371,51 +349,6 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult { return out } -func classifyFrameForAnalyze(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { - _ = ctx - - if !trainingRecognitionEnabled() { - return &NsfwImageResponse{ - Ok: true, - Results: []NsfwFrameResult{}, - }, nil - } - - tmp, err := os.CreateTemp("", "training-analyze-frame-*.jpg") - if err != nil { - return nil, err - } - - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - - if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil { - _ = tmp.Close() - return nil, err - } - - if err := tmp.Close(); err != nil { - return nil, err - } - - pred := trainingPredictFrameDetectorOnly(tmpPath) - - // Wichtig: kein Fallback mehr auf altes ONNX-NSFW-Modell. - if !pred.ModelAvailable { - return &NsfwImageResponse{ - Ok: true, - Results: []NsfwFrameResult{}, - }, nil - } - - results := trainingPredictionToNSFWResults(pred) - - return &NsfwImageResponse{ - Ok: true, - Results: results, - }, nil -} - func predictFrameForAnalyze(ctx context.Context, img image.Image) TrainingPrediction { _ = ctx @@ -458,33 +391,6 @@ func predictFrameForAnalyze(ctx context.Context, img image.Image) TrainingPredic return trainingPredictFrame(tmpPath) } -func classifyFramePathForAnalyze(ctx context.Context, framePath string) (*NsfwImageResponse, error) { - _ = ctx - - if !trainingRecognitionEnabled() { - return &NsfwImageResponse{ - Ok: true, - Results: []NsfwFrameResult{}, - }, nil - } - - pred := trainingPredictFrameDetectorOnly(framePath) - - if !pred.ModelAvailable { - return &NsfwImageResponse{ - Ok: true, - Results: []NsfwFrameResult{}, - }, nil - } - - results := trainingPredictionToNSFWResults(pred) - - return &NsfwImageResponse{ - Ok: true, - Results: results, - }, nil -} - func predictFramePathForAnalyze(ctx context.Context, framePath string) TrainingPrediction { _ = ctx @@ -498,7 +404,7 @@ func predictFramePathForAnalyze(ctx context.Context, framePath string) TrainingP type analyzeBatchPredictReq struct { Paths []string `json:"paths"` DetectorOnly bool `json:"detectorOnly"` - ImageSize int `json:"imageSize"` + ImageSize int `json:"imageSize,omitempty"` Model string `json:"model,omitempty"` } @@ -548,7 +454,10 @@ func trainingPredictFramePathsBatchForAnalyze( payload := analyzeBatchPredictReq{ Paths: cleanPaths, DetectorOnly: detectorOnly, - ImageSize: analyzeVideoFrameWidth, + } + + if analyzeVideoFrameWidth > 0 { + payload.ImageSize = analyzeVideoFrameWidth } body, err := json.Marshal(payload) @@ -583,12 +492,32 @@ func trainingPredictFramePathsBatchForAnalyze( } defer res.Body.Close() - var parsed analyzeBatchPredictResp - if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil { + rawBody, readErr := io.ReadAll(res.Body) + if readErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } - return nil, err + return nil, readErr + } + + var parsed analyzeBatchPredictResp + if err := json.Unmarshal(rawBody, &parsed); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + + appLogf( + "❌ [analyze] AI server invalid JSON status=%d url=%s body=%s", + res.StatusCode, + url, + strings.TrimSpace(string(rawBody)), + ) + + return nil, appErrorf( + "AI server lieferte ungültiges JSON: HTTP %d: %s", + res.StatusCode, + strings.TrimSpace(string(rawBody)), + ) } if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { @@ -596,6 +525,15 @@ func trainingPredictFramePathsBatchForAnalyze( if msg == "" { msg = fmt.Sprintf("AI server HTTP %d", res.StatusCode) } + + appLogf( + "❌ [analyze] AI server error status=%d url=%s error=%s body=%s", + res.StatusCode, + url, + msg, + strings.TrimSpace(string(rawBody)), + ) + return nil, appErrorf("%s", msg) } @@ -606,67 +544,6 @@ func trainingPredictFramePathsBatchForAnalyze( return parsed.Predictions, nil } -func nsfwLabelPriority(label string) int { - label = strings.ToLower(strings.TrimSpace(label)) - - switch label { - case "vulva", "pussy": - return 1000 - - case "penis": - return 950 - - case "anus": - return 900 - - case "breasts": - return 800 - - case "buttocks", "ass": - return 700 - - default: - if shouldAutoSelectAnalyzeHit(label) { - return 500 - } - return 0 - } -} - -func pickBestNSFWResult(results []NsfwFrameResult) (string, float64) { - bestLabel := "" - bestScore := 0.0 - bestPriority := -1 - - for _, r := range results { - label := strings.ToLower(strings.TrimSpace(r.Label)) - if label == "" { - continue - } - if isIgnoredNSFWLabel(label) { - continue - } - - score := r.Score - priority := nsfwLabelPriority(label) - - if priority > bestPriority { - bestLabel = label - bestScore = score - bestPriority = priority - continue - } - - if priority == bestPriority && score > bestScore { - bestLabel = label - bestScore = score - bestPriority = priority - } - } - - return bestLabel, bestScore -} - func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (image.Image, error) { tmp, err := os.CreateTemp("", "nsfw-frame-*.jpg") if err != nil { @@ -681,17 +558,23 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im ffmpegPath = "ffmpeg" } - cmd := exec.CommandContext( - ctx, - ffmpegPath, + args := []string{ "-ss", fmt.Sprintf("%.3f", atSec), "-i", outPath, "-frames:v", "1", - "-vf", fmt.Sprintf("scale=%d:-2:flags=fast_bilinear", analyzeVideoFrameWidth), + } + + if vf := analyzeSingleFrameFilter(); vf != "" { + args = append(args, "-vf", vf) + } + + args = append(args, "-q:v", "2", "-y", tmpPath, ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, // CREATE_NO_WINDOW @@ -816,11 +699,7 @@ func extractVideoFramesBatch( // intervalSeconds=1 -> 1 Frame pro Sekunde // intervalSeconds=2 -> 1 Frame alle 2 Sekunden // intervalSeconds=5 -> 1 Frame alle 5 Sekunden - vf := fmt.Sprintf( - "fps=1/%d,scale=%d:-2:flags=fast_bilinear", - intervalSeconds, - analyzeVideoFrameWidth, - ) + vf := analyzeVideoFrameFilter(intervalSeconds) cmd := exec.CommandContext( ctx, @@ -914,10 +793,18 @@ func extractVideoFramesBatch( } if waitErr != nil { + msg := strings.TrimSpace(stderr.String()) + appLogf( + "❌ [analyze] ffmpeg frame extract failed input=%q err=%v stderr=%s", + outPath, + waitErr, + msg, + ) + return nil, nil, appErrorf( "ffmpeg frames extrahieren fehlgeschlagen: %v: %s", waitErr, - strings.TrimSpace(stderr.String()), + msg, ) } @@ -973,46 +860,49 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { } var req analyzeVideoReq + file := "" + outPath := "" + + defer func() { + if rec := recover(); rec != nil { + msg := fmt.Sprintf("panic in analyse: %v", rec) + appLogln("❌ [analyze]", msg) + appLogln("❌ [analyze] stack:\n" + string(debug.Stack())) + + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: "video", + Goal: "highlights", + Hits: []analyzeHit{}, + Error: msg, + }) + } + }() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "ungültiger body: "+err.Error(), http.StatusBadRequest) + logAnalyzeError("decode-request", "", "", err) + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: "video", + Goal: "highlights", + Hits: []analyzeHit{}, + Error: "ungültiger body: " + err.Error(), + }) return } req.Mode = "video" - req.Goal = strings.ToLower(strings.TrimSpace(req.Goal)) + req.Goal = "highlights" - if req.Goal == "" { - req.Goal = "highlights" - } + outPath = strings.TrimSpace(req.Output) + file = filepath.Base(outPath) - // Sprite-Modus ist deaktiviert, weil kein predict_sprite_batch.py vorhanden ist. - // Analyse läuft immer über den Video-Frame-Batch-Pfad. + appLogf("🧪 [analyze] request file=%q path=%q", file, outPath) - switch req.Goal { - case "highlights", "nsfw": - default: - http.Error(w, "goal muss 'highlights' oder 'nsfw' sein", http.StatusBadRequest) - return - } - - outPath := strings.TrimSpace(req.Output) if outPath == "" { - http.Error(w, "output fehlt", http.StatusBadRequest) - return - } + err := appErrorf("output fehlt") + logAnalyzeError("validate-output", file, outPath, err) - fi, err := os.Stat(outPath) - if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - http.Error(w, "output datei nicht gefunden", http.StatusNotFound) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) - defer cancel() - - hits, err := analyzeVideoFromFrames(ctx, outPath, req.Goal) - - if err != nil { respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, @@ -1023,17 +913,78 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - durationSec, _ := durationSecondsForAnalyze(ctx, outPath) - segments := buildAnalyzeSegmentsForGoal(hits, durationSec, req.Goal) + fi, err := os.Stat(outPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + if err == nil { + err = appErrorf("output datei nicht gefunden oder leer") + } + logAnalyzeError("stat-output", file, outPath, err) - var rating *aiRatingMeta - if req.Goal == "nsfw" || req.Goal == "highlights" { - rating = computeNSFWRating(segments, durationSec) + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: req.Mode, + Goal: req.Goal, + Hits: []analyzeHit{}, + Error: "output datei nicht gefunden: " + err.Error(), + }) + return } + appLogf( + "🧪 [analyze] file ok file=%q size=%d mod=%s", + file, + fi.Size(), + fi.ModTime().Format(time.RFC3339), + ) + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) + defer cancel() + + hits, err := analyzeVideoFromFrames(ctx, outPath) + if err != nil { + logAnalyzeError("analyze-frames", file, outPath, err) + + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: req.Mode, + Goal: req.Goal, + Hits: []analyzeHit{}, + Error: err.Error(), + }) + return + } + + appLogf("🧪 [analyze] hits file=%q count=%d", file, len(hits)) + + durationSec, derr := durationSecondsForAnalyze(ctx, outPath) + if derr != nil { + logAnalyzeError("duration-after-analyze", file, outPath, derr) + } + if durationSec <= 0 { + err := appErrorf("videolänge konnte nach analyse nicht bestimmt werden") + logAnalyzeError("duration-invalid", file, outPath, err) + + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: req.Mode, + Goal: req.Goal, + Hits: hits, + Error: err.Error(), + }) + return + } + + segments := buildAnalyzeSegmentsForGoal(hits, durationSec) + appLogf("🧪 [analyze] raw segments file=%q count=%d", file, len(segments)) + + segments = prepareAIRatingSegments(segments) + appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments)) + + rating := computeHighlightRating(segments, durationSec) + ai := &aiAnalysisMeta{ - Goal: req.Goal, - Mode: req.Mode, + Goal: "highlights", + Mode: "video", Hits: hits, Segments: segments, Rating: rating, @@ -1041,9 +992,39 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { } if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { - appLogln("⚠️ writeVideoAIForFile:", err) + logAnalyzeError("write-meta-ai", file, outPath, err) + + respondJSON(w, analyzeVideoResp{ + OK: false, + Mode: req.Mode, + Goal: req.Goal, + Hits: hits, + Segments: segments, + Rating: rating, + Error: "analyse fertig, aber meta konnte nicht gespeichert werden: " + err.Error(), + }) + return } + appLogf( + "✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d", + file, + len(hits), + len(segments), + func() float64 { + if rating == nil { + return 0 + } + return rating.Score + }(), + func() int { + if rating == nil { + return 0 + } + return rating.Stars + }(), + ) + respondJSON(w, analyzeVideoResp{ OK: true, Mode: req.Mode, @@ -1054,69 +1035,6 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { }) } -func nsfwThresholdForLabel(label string) float64 { - label = strings.ToLower(strings.TrimSpace(label)) - - switch label { - case "vulva", "penis", "anus": - return nsfwThresholdStrong - - case "pussy", "breasts", "buttocks", "ass": - return nsfwThresholdModerate - - default: - if shouldAutoSelectAnalyzeHit(label) { - return 0.40 - } - return 0.50 - } -} - -func appendNSFWHitFromPrediction( - hits []analyzeHit, - pred TrainingPrediction, - t float64, -) []analyzeHit { - if !pred.ModelAvailable { - appLogln("⚠️ nsfw: modelAvailable=false bei", t) - return hits - } - - nsfwResults := trainingPredictionToNSFWResults(pred) - if len(nsfwResults) == 0 { - return hits - } - - for _, r := range nsfwResults { - label := strings.ToLower(strings.TrimSpace(r.Label)) - if label == "" || label == "unknown" { - continue - } - if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { - continue - } - - score := r.Score - if score <= 0 { - score = 1 - } - - if score < nsfwThresholdForLabel(label) { - continue - } - - hits = append(hits, analyzeHit{ - Time: t, - Label: label, - Score: score, - Start: t, - End: t, - }) - } - - return hits -} - type highlightSignal struct { Label string Score float64 @@ -1216,6 +1134,38 @@ func highlightSignalGroup(label string) string { } } +func rawAnalyzeSignalSeverityWeight(label string) float64 { + label = normalizeHighlightSignalLabel(label) + if label == "" { + return 0 + } + + raw := normalizeSegmentLabel(label) + if raw == "" || raw == "unknown" { + return 0 + } + + switch { + case strings.HasPrefix(label, "position:"): + return positionSeverityWeight(raw) + + case strings.HasPrefix(label, "body:"): + return bodyPartSeverityWeight(raw) + + case strings.HasPrefix(label, "object:"): + return objectSeverityWeight(raw) + + case strings.HasPrefix(label, "clothing:"): + return clothingSeverityWeight(raw) + + case strings.HasPrefix(label, "detector:"): + return detectorSeverityWeight(raw) + + default: + return detectorSeverityWeight(raw) + } +} + func highlightSignalInterestingEnough(label string, score float64) bool { label = normalizeHighlightSignalLabel(label) if label == "" { @@ -1226,23 +1176,23 @@ func highlightSignalInterestingEnough(label string, score float64) bool { score = 1 } + rawSev := rawAnalyzeSignalSeverityWeight(label) + switch { case strings.HasPrefix(label, "position:"): - // Position alleine ist nicht interessant genug, aber als Kombi-Kontext okay. - return score >= 0.35 + return score >= analyzeMinPositionScore && rawSev > 0 case strings.HasPrefix(label, "body:"): - return score >= 0.35 && segmentSeverityWeight(label) >= 0.65 + return score >= analyzeMinBodyScore && rawSev >= 0.65 case strings.HasPrefix(label, "object:"): - return score >= 0.35 && segmentSeverityWeight(label) >= 0.50 + return score >= analyzeMinObjectScore && rawSev >= 0.50 case strings.HasPrefix(label, "clothing:"): - // Kleidung nur anzeigen, wenn sie als Kombi-Kontext dient. - return score >= 0.45 && segmentSeverityWeight(label) >= 0.50 + return score >= analyzeMinClothingScore && rawSev >= 0.50 case strings.HasPrefix(label, "detector:"): - return score >= 0.45 && segmentSeverityWeight(label) >= 0.60 + return score >= analyzeMinDetectorScore && rawSev >= 0.60 default: return false @@ -1404,13 +1354,21 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) sev := segmentSeverityWeight(bestSingle.Label) // Nur wirklich relevante Einzel-Signale übernehmen. - if sev < 0.65 { + // Die Grenzwerte stehen oben als Konstanten, damit du sie später + // leichter feintunen kannst: + // + // analyzeMinSingleSeverity = 0.60 + // analyzeMinSingleScore = 0.25 + // analyzeMinSingleQuality = 0.24 + if sev < analyzeMinSingleSeverity { return analyzeHit{}, false } - if bestSingle.Score < 0.35 { + + if bestSingle.Score < analyzeMinSingleScore { return analyzeHit{}, false } - if bestSingleQuality < 0.32 { + + if bestSingleQuality < analyzeMinSingleQuality { return analyzeHit{}, false } @@ -1496,7 +1454,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) } // Noch einmal Mindestqualität prüfen. - if score < 0.42 { + if score < analyzeMinComboScore { return analyzeHit{}, false } @@ -1581,6 +1539,15 @@ func appendHighlightHitsFromPrediction( pred TrainingPrediction, t float64, ) []analyzeHit { + // Wichtig: + // Wenn eine Kombi erkannt wurde, KEINE Einzel-Hits aus demselben Frame + // zusätzlich anhängen. Sonst entstehen z. B. gleichzeitig: + // - combo:position:toy_play+object:vibrator + // - object:vibrator + if combined, ok := buildCombinedHighlightHitFromPrediction(pred, t); ok { + return append(hits, combined) + } + next := buildHighlightHitsFromPrediction(pred, t) if len(next) == 0 { return hits @@ -1589,26 +1556,31 @@ func appendHighlightHitsFromPrediction( return append(hits, next...) } -func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) { - goal = strings.ToLower(strings.TrimSpace(goal)) - - nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(ctx, outPath, goal) - if err != nil { - return nil, err - } - - switch goal { - case "nsfw": - return nsfwHits, nil - case "highlights": - return highlightHits, nil - default: - return []analyzeHit{}, nil - } +func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, error) { + return analyzeVideoFromFramesForGoal(ctx, outPath) } const analyzeProgressTotal = 1000 +func logAnalyzeError(stage string, file string, outPath string, err error) { + if err == nil { + return + } + + appLogf( + "❌ [analyze] stage=%s file=%q path=%q err=%v", + strings.TrimSpace(stage), + strings.TrimSpace(file), + strings.TrimSpace(outPath), + err, + ) +} + +func logAnalyzeInfo(stage string, file string, format string, args ...any) { + prefix := fmt.Sprintf("🧪 [analyze] stage=%s file=%q ", strings.TrimSpace(stage), strings.TrimSpace(file)) + appLogf(prefix+format, args...) +} + func publishAnalyzeExtractProgress( startedAtMs int64, file string, @@ -1710,13 +1682,7 @@ func analyzeGlobalPercentMessageFromCurrent(current int, total int) string { func analyzeVideoFromFramesForGoal( ctx context.Context, outPath string, - goal string, -) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { - goal = strings.ToLower(strings.TrimSpace(goal)) - if goal == "" { - goal = "all" - } - +) (highlightHits []analyzeHit, err error) { file := filepath.Base(strings.TrimSpace(outPath)) startedAtMs := publishAnalysisStarted(file, analyzeProgressTotal, "Analyse 0%") @@ -1729,19 +1695,19 @@ func analyzeVideoFromFramesForGoal( if err := ctx.Err(); err != nil { publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) - return nil, nil, err + return nil, err } durationSec, _ := durationSecondsForAnalyze(ctx, outPath) if err := ctx.Err(); err != nil { publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) - return nil, nil, err + return nil, err } if durationSec <= 0 { err := appErrorf("videolänge konnte nicht bestimmt werden") publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) - return nil, nil, err + return nil, err } publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) { @@ -1800,14 +1766,14 @@ func analyzeVideoFromFramesForGoal( *lastPercent = nextPercent } - failCancelled := func() ([]analyzeHit, []analyzeHit, error) { + failCancelled := func() ([]analyzeHit, error) { err := ctx.Err() if err == nil { err = context.Canceled } publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) - return nil, nil, err + return nil, err } lastExtractPercent := 0 @@ -1853,13 +1819,13 @@ func analyzeVideoFromFramesForGoal( if err != nil { publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err) - return nil, nil, err + return nil, err } if len(samples) == 0 { err := appErrorf("keine frame-samples vorhanden") publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err) - return nil, nil, err + return nil, err } total := len(samples) @@ -1880,7 +1846,7 @@ func analyzeVideoFromFramesForGoal( paths := make([]string, 0, len(samples)) for _, sample := range samples { - if err := ctx.Err(); err != nil { + if ctx.Err() != nil { return failCancelled() } @@ -1901,164 +1867,126 @@ func analyzeVideoFromFramesForGoal( return failCancelled() } - // Schneller AI-Server-Batch-Pfad für nsfw, highlights und all. - // Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf. - // Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück. - if goal == "nsfw" || goal == "highlights" || goal == "all" { - batchOK := true + batchOK := true + detectorOnly := false - // Für nsfw könnte detectorOnly=true reichen. - // Dein ai_server.py liefert aber ohnehin alle Felder aus YOLO-Resultaten, - // daher ist false für alle Goals okay und vermeidet Sonderlogik. - detectorOnly := false + for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { + if ctx.Err() != nil { + return failCancelled() + } - for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { - if ctx.Err() != nil { - return failCancelled() + endIdx := startIdx + analyzeFramePredictBatchSize + if endIdx > len(samples) { + endIdx = len(samples) + } + + predictions, batchErr := trainingPredictFramePathsBatchForAnalyze( + ctx, + paths[startIdx:endIdx], + detectorOnly, + ) + + if ctx.Err() != nil { + return failCancelled() + } + + if batchErr != nil || len(predictions) < endIdx-startIdx { + if batchErr != nil { + appLogf( + "❌ [analyze] batch failed file=%q batch=%d:%d err=%v", + file, + startIdx, + endIdx, + batchErr, + ) + } else { + appLogf( + "❌ [analyze] batch returned too few predictions file=%q batch=%d:%d got=%d expected=%d", + file, + startIdx, + endIdx, + len(predictions), + endIdx-startIdx, + ) } - endIdx := startIdx + analyzeFramePredictBatchSize - if endIdx > len(samples) { - endIdx = len(samples) - } + appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse") - predictions, batchErr := trainingPredictFramePathsBatchForAnalyze( - ctx, - paths[startIdx:endIdx], - detectorOnly, + batchOK = false + highlightHits = nil + lastInferencePercent = 50 + + publishAnalyzeInferenceProgress( + startedAtMs, + file, + 0, + total, + "Analyse 50%", ) + break + } + + for i := 0; i < endIdx-startIdx; i++ { if ctx.Err() != nil { return failCancelled() } - if batchErr != nil || len(predictions) < endIdx-startIdx { - appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr) + sample := samples[startIdx+i] + pred := predictions[i] - batchOK = false - nsfwHits = nil - highlightHits = nil - lastInferencePercent = 50 + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + } - publishAnalyzeInferenceProgress( - startedAtMs, - file, - 0, - total, - "Analyse 50%", - ) + globalPercent := 50 + int(math.Round((float64(endIdx)/float64(total))*50)) + if globalPercent > 100 { + globalPercent = 100 + } - break - } + publishPercentRange( + &lastInferencePercent, + globalPercent, + endIdx, + total, + false, + ) + } - for i := 0; i < endIdx-startIdx; i++ { - if ctx.Err() != nil { - return failCancelled() - } - - sample := samples[startIdx+i] - pred := predictions[i] - - switch goal { - case "nsfw": - nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) - - case "highlights": - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) - - default: - nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) - } - } - - globalPercent := 50 + int(math.Round((float64(endIdx)/float64(total))*50)) - if globalPercent > 100 { - globalPercent = 100 - } + if batchOK { + if ctx.Err() != nil { + return failCancelled() + } + if lastInferencePercent < 100 { publishPercentRange( &lastInferencePercent, - globalPercent, - endIdx, + 100, + total, total, false, ) } - if batchOK { - if ctx.Err() != nil { - return failCancelled() - } + cleanHighlightHits := mergeAnalyzeHits(highlightHits) - if lastInferencePercent < 100 { - publishPercentRange( - &lastInferencePercent, - 100, - total, - total, - false, - ) - } - - cleanNSFWHits := mergeAnalyzeHits(nsfwHits) - cleanHighlightHits := mergeAnalyzeHits(highlightHits) - - publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") - return cleanNSFWHits, cleanHighlightHits, nil - } + publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") + return cleanHighlightHits, nil } - // Fallback: langsame Einzelbild-Analyse. - // Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt. + // Fallback: langsame Einzelbild-Analyse, nur wenn der AI-Server-Batch fehlschlägt. for i, sample := range samples { if ctx.Err() != nil { return failCancelled() } - t := sample.Time + pred := predictFramePathForAnalyze(ctx, sample.Path) - switch goal { - case "nsfw": - res, frameErr := classifyFramePathForAnalyze(ctx, sample.Path) - - if ctx.Err() != nil { - return failCancelled() - } - - if frameErr == nil { - bestLabel, bestScore := pickBestNSFWResult(res.Results) - if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { - nsfwHits = append(nsfwHits, analyzeHit{ - Time: t, - Label: bestLabel, - Score: bestScore, - Start: t, - End: t, - }) - } - } - - case "highlights": - pred := predictFramePathForAnalyze(ctx, sample.Path) - - if ctx.Err() != nil { - return failCancelled() - } - - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) - - default: - pred := predictFramePathForAnalyze(ctx, sample.Path) - - if ctx.Err() != nil { - return failCancelled() - } - - nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t) - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) + if ctx.Err() != nil { + return failCancelled() } + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + current := i + 1 globalPercent := 50 + int(math.Round((float64(current)/float64(total))*50)) @@ -2089,12 +2017,11 @@ func analyzeVideoFromFramesForGoal( ) } - cleanNSFWHits := mergeAnalyzeHits(nsfwHits) cleanHighlightHits := mergeAnalyzeHits(highlightHits) publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") - return cleanNSFWHits, cleanHighlightHits, nil + return cleanHighlightHits, nil } func sameAnalyzeComboLabel(a, b string) bool { @@ -2775,20 +2702,8 @@ func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) func buildAnalyzeSegmentsForGoal( hits []analyzeHit, duration float64, - goal string, ) []aiSegmentMeta { - goal = strings.ToLower(strings.TrimSpace(goal)) - - switch goal { - case "highlights": - return buildHighlightSegmentsFromAnalyzeHits(hits, duration) - - case "nsfw": - return buildSegmentsFromAnalyzeHits(hits, duration) - - default: - return []aiSegmentMeta{} - } + return buildHighlightSegmentsFromAnalyzeHits(hits, duration) } func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) { diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 7e3d582..d50803d 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -3,6 +3,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -104,7 +105,7 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m } func requiredAnalyzeGoals() []string { - return []string{"nsfw", "highlights"} + return []string{"highlights"} } func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool { @@ -133,8 +134,6 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( return false, nil } - // Normaler Asset-/Postwork-Pfad: - // vorhandene Analyse behalten. if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) { return false, nil } @@ -167,45 +166,26 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( return false, appErrorf("videolänge konnte nicht bestimmt werden") } - // NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren. - nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal( - actx, - videoPath, - "all", - ) + hits, err := analyzeVideoFromFrames(actx, videoPath) if err != nil { return false, err } - nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw") - nsfwRating := computeNSFWRating(nsfwSegments, durationSec) + segments := buildAnalyzeSegmentsForGoal(hits, durationSec) + segments = prepareAIRatingSegments(segments) - nsfwAI := &aiAnalysisMeta{ - Goal: "nsfw", - Mode: "video", - Hits: nsfwHits, - Segments: nsfwSegments, - Rating: nsfwRating, - AnalyzedAtUnix: time.Now().Unix(), - } + rating := computeHighlightRating(segments, durationSec) - if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil { - return false, err - } - - highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights") - highlightRating := computeNSFWRating(highlightSegments, durationSec) - - highlightAI := &aiAnalysisMeta{ + ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", - Hits: highlightHits, - Segments: highlightSegments, - Rating: highlightRating, + Hits: hits, + Segments: segments, + Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } - if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, highlightAI); err != nil { + if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil { return false, err } @@ -241,8 +221,8 @@ func assetsTruthForVideo(videoPath string) finishedPhaseTruth { truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.SpritesReady = fileExistsNonEmpty(spritePath) - // Analyse für Standard-Postwork: NSFW-Rating - truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) + // Analyse läuft frame-basiert und braucht kein Sprite mehr. + truth.AnalyzeReady = hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) return truth } @@ -890,10 +870,7 @@ func ensureAnalyzeForVideoCtx( return false, nil } - goal = normalizeAIGoal(goal) - if goal == "" { - goal = "nsfw" - } + goal = "highlights" if hasAIAnalysisForOutputGoal(videoPath, goal) { return false, nil @@ -914,7 +891,6 @@ func ensureAnalyzeForVideoCtx( return false, perr } - // Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr. if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil { return false, err } @@ -948,10 +924,7 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string, return appErrorf("deferred analyze failed for %s: %w", videoPath, err) } - goal = normalizeAIGoal(goal) - if goal == "" { - goal = "highlights" - } + goal = "highlights" if !hasAIAnalysisForOutputGoal(videoPath, goal) { return appErrorf("Analyse fehlt nach deferred analyze: %s", id) @@ -1008,7 +981,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil) } sortBucket, sortName := postWorkSortForVideoPath(outPath) @@ -1046,7 +1019,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen konnten nicht eingeplant werden", enqueueErr) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr) } return false @@ -1381,35 +1354,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return out, nil } -func hasAIResultsForOutputGoal(outPath string, goal string) bool { - outPath = strings.TrimSpace(outPath) - if outPath == "" { - return false - } - - id := assetIDFromVideoPath(outPath) - if id == "" { - return false - } - - metaPath, err := generatedMetaFile(id) - if err != nil || strings.TrimSpace(metaPath) == "" { - return false - } - - goal = normalizeAIGoal(goal) - - aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) - if !ok || aiMap == nil { - return false - } - - rawHits, hasHits := aiMap["hits"].([]any) - rawSegs, hasSegs := aiMap["segments"].([]any) - - return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) -} - func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { @@ -1426,40 +1370,63 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { return false } - goal = normalizeAIGoal(goal) - - aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) + aiMap, ok := readVideoMetaAIForGoal(metaPath, "highlights") if !ok || aiMap == nil { return false } + // Wichtig: + // Eine Analyse mit 0 Hits / 0 Segments ist trotzdem eine fertige Analyse. + // Sonst zeigt das Frontend fälschlich "Analyse fehlt". + if jsonNumberPositive(aiMap["analyzedAtUnix"]) { + return true + } + + if rating, ok := aiMap["rating"].(map[string]any); ok && rating != nil { + return true + } + + // Legacy-Fallback für alte meta.json-Dateien. rawHits, hasHits := aiMap["hits"].([]any) rawSegs, hasSegs := aiMap["segments"].([]any) - // Wichtig: - // Eine leere Analyse zählt NICHT mehr als fertig. - // Sonst bleibt ein kaputter 0-Treffer-Run für immer gecached. return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) } -func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool { - outPath = strings.TrimSpace(outPath) - if outPath == "" { +func jsonNumberPositive(v any) bool { + switch x := v.(type) { + case json.Number: + i, err := x.Int64() + return err == nil && i > 0 + case float64: + return x > 0 + case int64: + return x > 0 + case int: + return x > 0 + default: return false } +} - if len(goals) == 0 { - goals = requiredAnalyzeGoals() +func isPositiveJSONNumber(v any) bool { + switch x := v.(type) { + case json.Number: + i, err := x.Int64() + return err == nil && i > 0 + case float64: + return x > 0 + case int64: + return x > 0 + case int: + return x > 0 + default: + return false } +} - for _, goal := range goals { - goal = normalizeAIGoal(goal) - if !hasAIAnalysisForOutputGoal(outPath, goal) { - return false - } - } - - return true +func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool { + return hasAIAnalysisForOutputGoal(outPath, "highlights") } type PrepareSplitResult struct { @@ -1519,16 +1486,12 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string out.AssetsReady = thumbExists && previewExists - goal = strings.ToLower(strings.TrimSpace(goal)) - if goal == "" { - goal = "highlights" - } + goal = "highlights" // 2) Für Split brauchen wir zusätzlich Sprite + AI. // Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen. - // (Im normalen Record-Flow laufen sie separat im Hintergrund.) + // Im normalen Record-Flow laufen sie separat im Hintergrund. if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL, goal); err != nil { - // best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten appLogln("⚠️ prepareVideoForSplit deferred enrich:", err) } @@ -1541,29 +1504,29 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string out.SpriteReady = spriteExists - // 3) AI-Segmente prüfen - if hasAIAnalysisForOutputGoal(videoPath, goal) { + if hasAIAnalysisForOutputGoal(videoPath, "highlights") { out.AnalyzeReady = true return out, nil } - // 4) Fallback: AI direkt berechnen durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) - hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal) + if durationSec <= 0 { + return out, nil + } + + hits, aerr := analyzeVideoFromFrames(ctx, videoPath) if aerr != nil { return out, nil } - segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal) + segments := buildAnalyzeSegmentsForGoal(hits, durationSec) + segments = prepareAIRatingSegments(segments) - var rating *aiRatingMeta - if goal == "nsfw" || goal == "highlights" { - rating = computeNSFWRating(segments, durationSec) - } + rating := computeHighlightRating(segments, durationSec) ai := &aiAnalysisMeta{ - Goal: goal, - Mode: "sprite", + Goal: "highlights", + Mode: "video", Hits: hits, Segments: segments, Rating: rating, @@ -1574,6 +1537,6 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string return out, nil } - out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal) + out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights") return out, nil } diff --git a/backend/auth.go b/backend/auth.go index c915974..8cdaccb 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -14,16 +14,33 @@ import ( "sync" "time" + "github.com/go-webauthn/webauthn/webauthn" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) +type passkeyInfo struct { + CredentialID string `json:"credentialId"` + Label string `json:"label"` + CreatedAt string `json:"createdAt,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` +} + +type totpInfo struct { + Label string `json:"label,omitempty"` + ConfiguredAt string `json:"configuredAt,omitempty"` +} + type authConfig struct { - Username string `json:"username"` - PasswordHash string `json:"passwordHash"` // bcrypt hash - TOTPEnabled bool `json:"totpEnabled"` - TOTPSecret string `json:"totpSecret"` // base32 + Username string `json:"username"` + PasswordHash string `json:"passwordHash"` + TOTPEnabled bool `json:"totpEnabled"` + TOTPSecret string `json:"totpSecret"` + TOTPInfo totpInfo `json:"totpInfo,omitempty"` + PasskeyUserID string `json:"passkeyUserId,omitempty"` + Passkeys []webauthn.Credential `json:"passkeys,omitempty"` + PasskeyInfos []passkeyInfo `json:"passkeyInfos,omitempty"` } // Session in memory (Restart = logout für alle) @@ -43,6 +60,11 @@ type AuthManager struct { sessMu sync.Mutex sess map[string]*session + passkeyMu sync.Mutex + passkeySessions map[string]*webauthn.SessionData + + webAuthn *webauthn.WebAuthn + configPath string } @@ -53,7 +75,8 @@ const ( func NewAuthManager() (*AuthManager, error) { am := &AuthManager{ - sess: make(map[string]*session), + sess: make(map[string]*session), + passkeySessions: make(map[string]*webauthn.SessionData), } // optional: persist config in file @@ -75,10 +98,22 @@ func NewAuthManager() (*AuthManager, error) { // 3) sanity am.confMu.Lock() - defer am.confMu.Unlock() if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" { + am.confMu.Unlock() return nil, errors.New("AUTH_USER/AUTH_PASS_HASH fehlen (oder auth.json unvollständig)") } + am.confMu.Unlock() + + // 4) Passkey User-ID sicherstellen + if err := am.ensurePasskeyUserID(); err != nil { + return nil, err + } + + // 5) WebAuthn initialisieren + if err := am.initWebAuthn(); err != nil { + return nil, err + } + return am, nil } @@ -165,6 +200,51 @@ func atomicWriteFileCompat(path string, data []byte) error { return os.Rename(tmp, path) } +func credentialIDString(id []byte) string { + return base64.RawURLEncoding.EncodeToString(id) +} + +func shortCredentialID(id string) string { + id = strings.TrimSpace(id) + if len(id) <= 12 { + return id + } + return id[:6] + "…" + id[len(id)-6:] +} + +func findPasskeyInfo(infos []passkeyInfo, credentialID string) (passkeyInfo, bool) { + for _, info := range infos { + if strings.TrimSpace(info.CredentialID) == credentialID { + return info, true + } + } + return passkeyInfo{}, false +} + +func upsertPasskeyInfo(infos []passkeyInfo, next passkeyInfo) []passkeyInfo { + next.CredentialID = strings.TrimSpace(next.CredentialID) + if next.CredentialID == "" { + return infos + } + + for i := range infos { + if strings.TrimSpace(infos[i].CredentialID) == next.CredentialID { + if strings.TrimSpace(next.Label) != "" { + infos[i].Label = next.Label + } + if strings.TrimSpace(next.CreatedAt) != "" { + infos[i].CreatedAt = next.CreatedAt + } + if strings.TrimSpace(next.LastUsedAt) != "" { + infos[i].LastUsedAt = next.LastUsedAt + } + return infos + } + } + + return append(infos, next) +} + func generateRandomPassword() (string, error) { // 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren b := make([]byte, 24) @@ -379,11 +459,56 @@ func authMeHandler(am *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, _ := am.getSession(r) - // ✅ Konfiguration atomar lesen am.confMu.Lock() secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != "" totpFlag := am.conf.TOTPEnabled totpEnabled := totpFlag && secretPresent + totpConfigured := totpEnabled + passkeyCount := len(am.conf.Passkeys) + + totpDevice := map[string]any(nil) + if totpConfigured { + label := strings.TrimSpace(am.conf.TOTPInfo.Label) + if label == "" { + label = "Authenticator-App" + } + + totpDevice = map[string]any{ + "id": "totp", + "label": label, + "type": "Authenticator-App", + "configuredAt": strings.TrimSpace(am.conf.TOTPInfo.ConfiguredAt), + "active": totpEnabled, + } + } + + passkeys := make([]map[string]any, 0, len(am.conf.Passkeys)) + for i, cred := range am.conf.Passkeys { + credentialID := credentialIDString(cred.ID) + + info, ok := findPasskeyInfo(am.conf.PasskeyInfos, credentialID) + + label := strings.TrimSpace(info.Label) + if label == "" { + label = "Passkey " + shortCredentialID(credentialID) + } + + createdAt := strings.TrimSpace(info.CreatedAt) + lastUsedAt := strings.TrimSpace(info.LastUsedAt) + + passkeys = append(passkeys, map[string]any{ + "id": credentialID, + "label": label, + "type": "Passkey", + "createdAt": createdAt, + "lastUsedAt": lastUsedAt, + "signCount": cred.Authenticator.SignCount, + "backupState": cred.Flags.BackupState, + "backupEligible": cred.Flags.BackupEligible, + "index": i + 1, + "known": ok, + }) + } am.confMu.Unlock() w.Header().Set("Content-Type", "application/json") @@ -393,15 +518,14 @@ func authMeHandler(am *AuthManager) http.HandlerFunc { "authenticated": s != nil && s.Authed, "pending2fa": s != nil && s.Pending2FA, - // ✅ „wirklich aktiv“ (Flag + Secret vorhanden) - "totpEnabled": totpEnabled, + "totpEnabled": totpEnabled, + "totpConfigured": totpConfigured, + "totpFlag": totpFlag, + "totpDevice": totpDevice, - // ✅ Zusatzinfos für UI/Debug: - // Secret existiert schon (Setup gemacht), aber evtl. noch nicht enabled - "totpConfigured": secretPresent, - - // reiner Flag-Status (kann true sein, obwohl Secret fehlt) - "totpFlag": totpFlag, + "passkeyConfigured": passkeyCount > 0, + "passkeyCount": passkeyCount, + "passkeys": passkeys, }) } } @@ -541,6 +665,10 @@ func auth2FASetupHandler(am *AuthManager) http.HandlerFunc { } am.conf.TOTPSecret = key.Secret() am.conf.TOTPEnabled = false // erst nach Enable aktiv + am.conf.TOTPInfo = totpInfo{ + Label: "Authenticator-App", + ConfiguredAt: time.Now().Format(time.RFC3339), + } am.confMu.Unlock() // 4) persist ohne Lock @@ -641,3 +769,244 @@ func auth2FAEnableHandler(am *AuthManager) http.HandlerFunc { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } } + +type passwordChangeReq struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +func authPasswordChangeHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + s, sid := am.getSession(r) + if s == nil || !s.Authed || sid == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req passwordChangeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + currentPassword := strings.TrimSpace(req.CurrentPassword) + newPassword := strings.TrimSpace(req.NewPassword) + + if currentPassword == "" { + http.Error(w, "current password required", http.StatusBadRequest) + return + } + if len(newPassword) < 8 { + http.Error(w, "new password must be at least 8 characters", http.StatusBadRequest) + return + } + + am.confMu.Lock() + oldHash := am.conf.PasswordHash + am.confMu.Unlock() + + if err := bcrypt.CompareHashAndPassword([]byte(oldHash), []byte(currentPassword)); err != nil { + http.Error(w, "invalid current password", http.StatusUnauthorized) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "bcrypt failed", http.StatusInternalServerError) + return + } + + am.confMu.Lock() + am.conf.PasswordHash = string(hash) + am.confMu.Unlock() + + go am.persistConfBestEffort() + + // Alle anderen Sessions abmelden, aktuelle Session behalten. + am.sessMu.Lock() + cur := am.sess[sid] + am.sess = map[string]*session{} + if cur != nil { + am.sess[sid] = cur + } + am.sessMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +type disable2FAReq struct { + CurrentPassword string `json:"currentPassword"` + Code string `json:"code"` +} + +func auth2FADisableHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + s, _ := am.getSession(r) + if s == nil || !s.Authed { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req disable2FAReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + currentPassword := strings.TrimSpace(req.CurrentPassword) + code := strings.TrimSpace(req.Code) + + if currentPassword == "" { + http.Error(w, "current password required", http.StatusBadRequest) + return + } + + am.confMu.Lock() + hash := am.conf.PasswordHash + secret := strings.TrimSpace(am.conf.TOTPSecret) + totpEnabled := am.conf.TOTPEnabled && secret != "" + am.confMu.Unlock() + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(currentPassword)); err != nil { + http.Error(w, "invalid current password", http.StatusUnauthorized) + return + } + + if totpEnabled { + if code == "" { + http.Error(w, "2fa code required", http.StatusBadRequest) + return + } + if !totp.Validate(code, secret) { + http.Error(w, "invalid code", http.StatusUnauthorized) + return + } + } + + am.confMu.Lock() + am.conf.TOTPEnabled = false + am.conf.TOTPSecret = "" + am.conf.TOTPInfo = totpInfo{} + am.confMu.Unlock() + + go am.persistConfBestEffort() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +type passkeyDeleteReq struct { + CredentialID string `json:"credentialId"` +} + +func authPasskeyDeleteHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "DELETE required", http.StatusMethodNotAllowed) + return + } + + s, _ := am.getSession(r) + if s == nil || !s.Authed { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req passkeyDeleteReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + credentialID := strings.TrimSpace(req.CredentialID) + if credentialID == "" { + http.Error(w, "credentialId required", http.StatusBadRequest) + return + } + + am.confMu.Lock() + + nextPasskeys := make([]webauthn.Credential, 0, len(am.conf.Passkeys)) + removed := false + + for _, cred := range am.conf.Passkeys { + if credentialIDString(cred.ID) == credentialID { + removed = true + continue + } + nextPasskeys = append(nextPasskeys, cred) + } + + if !removed { + am.confMu.Unlock() + http.Error(w, "passkey not found", http.StatusNotFound) + return + } + + nextInfos := make([]passkeyInfo, 0, len(am.conf.PasskeyInfos)) + for _, info := range am.conf.PasskeyInfos { + if strings.TrimSpace(info.CredentialID) == credentialID { + continue + } + nextInfos = append(nextInfos, info) + } + + am.conf.Passkeys = nextPasskeys + am.conf.PasskeyInfos = nextInfos + + am.confMu.Unlock() + + go am.persistConfBestEffort() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +func auth2FACancelSetupHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + s, _ := am.getSession(r) + if s == nil || !s.Authed { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + am.confMu.Lock() + + // Nur ein unfertiges Setup entfernen. + // Wenn 2FA bereits aktiv ist, darf dieser Endpoint nichts deaktivieren. + if !am.conf.TOTPEnabled { + am.conf.TOTPSecret = "" + am.conf.TOTPInfo = totpInfo{} + } + + am.confMu.Unlock() + + go am.persistConfBestEffort() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} diff --git a/backend/auth_passkey.go b/backend/auth_passkey.go new file mode 100644 index 0000000..d6ea50e --- /dev/null +++ b/backend/auth_passkey.go @@ -0,0 +1,578 @@ +// backend\auth_passkey.go + +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +const passkeyChallengeCookieName = "pk_challenge" + +type recorderWebAuthnUser struct { + id []byte + name string + displayName string + credentials []webauthn.Credential +} + +func (u recorderWebAuthnUser) WebAuthnID() []byte { + return u.id +} + +func (u recorderWebAuthnUser) WebAuthnName() string { + return u.name +} + +func (u recorderWebAuthnUser) WebAuthnDisplayName() string { + return u.displayName +} + +func (u recorderWebAuthnUser) WebAuthnIcon() string { + return "" +} + +func (u recorderWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + return u.credentials +} + +func randomBase64URL(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (am *AuthManager) ensurePasskeyUserID() error { + am.confMu.Lock() + needsID := strings.TrimSpace(am.conf.PasskeyUserID) == "" + if needsID { + id, err := randomBase64URL(32) + if err != nil { + am.confMu.Unlock() + return err + } + am.conf.PasskeyUserID = id + } + am.confMu.Unlock() + + if needsID { + am.persistConfBestEffort() + } + + return nil +} + +func (am *AuthManager) initWebAuthn() error { + origins := authAllowedRPOrigins() + if len(origins) == 0 { + return errors.New("no WebAuthn RP origins configured") + } + + appLogln("🔐 WebAuthn allowed origins:", strings.Join(origins, ", ")) + appLogln("🔐 AUTH_RP_ID:", strings.TrimSpace(os.Getenv("AUTH_RP_ID"))) + + rpID, err := rpIDForOrigin(origins[0]) + if err != nil { + return err + } + + wa, err := webauthn.New(&webauthn.Config{ + RPDisplayName: "Recorder", + RPID: rpID, + RPOrigins: origins, + }) + if err != nil { + return err + } + + am.webAuthn = wa + return nil +} + +func authAllowedRPOrigins() []string { + raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS")) + + // Backward compatible: alter Einzelwert funktioniert weiter. + if raw == "" { + raw = strings.TrimSpace(os.Getenv("AUTH_RP_ORIGIN")) + } + + if raw == "" { + raw = strings.Join([]string{ + "https://l14pbbk95100006.tegdssd.de:9999", + "https://localhost:9999", + "https://127.0.0.1:9999", + "https://10.0.1.25:9999", + "http://localhost:9999", + "http://127.0.0.1:9999", + "http://10.0.1.25:9999", + "http://l14pbbk95100006.tegdssd.de:5173", + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://10.0.1.25:5173", + }, ",") + } + + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]bool{} + + for _, p := range parts { + origin := strings.TrimSpace(p) + origin = strings.TrimRight(origin, "/") + if origin == "" { + continue + } + + if seen[origin] { + continue + } + + seen[origin] = true + out = append(out, origin) + } + + return out +} + +func originFromRequest(r *http.Request) string { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin != "" { + return strings.TrimRight(origin, "/") + } + + proto := "http" + if isSecureRequest(r) { + proto = "https" + } + + host := strings.TrimSpace(r.Host) + if host == "" { + return "" + } + + return proto + "://" + host +} + +func rpIDFromOrigin(origin string) (string, error) { + u, err := url.Parse(strings.TrimSpace(origin)) + if err != nil { + return "", err + } + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + if host == "" { + return "", errors.New("origin has no hostname") + } + + if net.ParseIP(host) != nil { + return "", errors.New( + "Passkeys/WebAuthn funktionieren nicht mit einer IP-Adresse als RP-ID. " + + "Öffne die App über einen Hostnamen, z. B. https://recorder.home.arpa:9999", + ) + } + + return host, nil +} + +func rpIDIsValidForOrigin(rpID string, origin string) bool { + rpID = strings.ToLower(strings.TrimSpace(rpID)) + if rpID == "" { + return false + } + + u, err := url.Parse(strings.TrimSpace(origin)) + if err != nil { + return false + } + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + if host == "" { + return false + } + + // IP-Adressen dürfen bei WebAuthn nur exakt als RP-ID verwendet werden. + if net.ParseIP(host) != nil { + return rpID == host + } + + // localhost darf ebenfalls nur exakt localhost sein. + if host == "localhost" { + return rpID == "localhost" + } + + // Normale Domains: RP-ID darf Host selbst oder Parent-Domain sein. + return host == rpID || strings.HasSuffix(host, "."+rpID) +} + +func rpIDForOrigin(origin string) (string, error) { + configured := strings.TrimSpace(os.Getenv("AUTH_RP_ID")) + + if configured != "" { + if rpIDIsValidForOrigin(configured, origin) { + return configured, nil + } + + appLogln( + "⚠️ AUTH_RP_ID passt nicht zur aktuellen Origin, nutze Origin-Host:", + "AUTH_RP_ID=", configured, + "origin=", origin, + ) + } + + return rpIDFromOrigin(origin) +} + +func originIsAllowed(origin string, allowed []string) bool { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + + for _, a := range allowed { + if origin == strings.TrimRight(strings.TrimSpace(a), "/") { + return true + } + } + + return false +} + +func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) { + origin := originFromRequest(r) + if origin == "" { + return nil, errors.New("request origin missing") + } + + allowedOrigins := authAllowedRPOrigins() + if !originIsAllowed(origin, allowedOrigins) { + return nil, errors.New("origin not allowed for WebAuthn: " + origin) + } + + rpID, err := rpIDForOrigin(origin) + if err != nil { + return nil, err + } + + appLogln("🔐 WebAuthn Request:", "origin=", origin, "rpID=", rpID) + + return webauthn.New(&webauthn.Config{ + RPDisplayName: "Recorder", + RPID: rpID, + RPOrigins: []string{origin}, + }) +} + +func (am *AuthManager) webAuthnUser() (recorderWebAuthnUser, error) { + am.confMu.Lock() + defer am.confMu.Unlock() + + idRaw := strings.TrimSpace(am.conf.PasskeyUserID) + id, err := base64.RawURLEncoding.DecodeString(idRaw) + if err != nil { + return recorderWebAuthnUser{}, err + } + + username := strings.TrimSpace(am.conf.Username) + if username == "" { + username = "admin" + } + + return recorderWebAuthnUser{ + id: id, + name: username, + displayName: username, + credentials: append([]webauthn.Credential(nil), am.conf.Passkeys...), + }, nil +} + +func setPasskeyChallengeCookie(w http.ResponseWriter, id string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: passkeyChallengeCookieName, + Value: id, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + Expires: time.Now().Add(5 * time.Minute), + }) +} + +func clearPasskeyChallengeCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: passkeyChallengeCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Expires: time.Unix(0, 0), + MaxAge: -1, + SameSite: http.SameSiteLaxMode, + }) +} + +func (am *AuthManager) savePasskeySession(key string, data *webauthn.SessionData) { + am.passkeyMu.Lock() + defer am.passkeyMu.Unlock() + am.passkeySessions[key] = data +} + +func (am *AuthManager) takePasskeySession(key string) *webauthn.SessionData { + am.passkeyMu.Lock() + defer am.passkeyMu.Unlock() + + data := am.passkeySessions[key] + delete(am.passkeySessions, key) + return data +} + +func authPasskeyRegisterOptionsHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + s, sid := am.getSession(r) + if s == nil || !s.Authed || sid == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + user, err := am.webAuthnUser() + if err != nil { + http.Error(w, "passkey user error", http.StatusInternalServerError) + return + } + + wa, err := am.webAuthnForRequest(r) + if err != nil { + appLogln("passkey registration options config failed:", err) + http.Error(w, "passkey registration options config failed: "+err.Error(), http.StatusBadRequest) + return + } + + options, sessionData, err := wa.BeginRegistration( + user, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), + webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), + webauthn.WithExtensions(map[string]any{"credProps": true}), + ) + if err != nil { + http.Error(w, "passkey registration options failed", http.StatusInternalServerError) + return + } + + am.savePasskeySession("register:"+sid, sessionData) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(options) + } +} + +func authPasskeyRegisterVerifyHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + s, sid := am.getSession(r) + if s == nil || !s.Authed || sid == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sessionData := am.takePasskeySession("register:" + sid) + if sessionData == nil { + http.Error(w, "passkey challenge expired", http.StatusBadRequest) + return + } + + user, err := am.webAuthnUser() + if err != nil { + http.Error(w, "passkey user error", http.StatusInternalServerError) + return + } + + wa, err := am.webAuthnForRequest(r) + if err != nil { + appLogln("passkey registration verify config failed:", err) + http.Error(w, "passkey registration verify config failed: "+err.Error(), http.StatusBadRequest) + return + } + + credential, err := wa.FinishRegistration(user, *sessionData, r) + if err != nil { + appLogln("passkey registration verify failed:", err) + http.Error(w, "passkey registration verify failed: "+err.Error(), http.StatusUnauthorized) + return + } + + now := time.Now().Format(time.RFC3339) + credentialID := credentialIDString(credential.ID) + + am.confMu.Lock() + am.conf.Passkeys = append(am.conf.Passkeys, *credential) + am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{ + CredentialID: credentialID, + Label: "Passkey " + shortCredentialID(credentialID), + CreatedAt: now, + }) + am.confMu.Unlock() + + go am.persistConfBestEffort() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +func authPasskeyLoginOptionsHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + user, err := am.webAuthnUser() + if err != nil { + http.Error(w, "passkey user error", http.StatusInternalServerError) + return + } + + if len(user.credentials) == 0 { + http.Error(w, "no passkey configured", http.StatusBadRequest) + return + } + + wa, err := am.webAuthnForRequest(r) + if err != nil { + appLogln("passkey login options config failed:", err) + http.Error(w, "passkey login options config failed: "+err.Error(), http.StatusBadRequest) + return + } + + options, sessionData, err := wa.BeginLogin( + user, + webauthn.WithUserVerification(protocol.VerificationPreferred), + ) + if err != nil { + http.Error(w, "passkey login options failed", http.StatusInternalServerError) + return + } + + challengeID, err := randomBase64URL(24) + if err != nil { + http.Error(w, "challenge error", http.StatusInternalServerError) + return + } + + am.savePasskeySession("login:"+challengeID, sessionData) + setPasskeyChallengeCookie(w, challengeID, isSecureRequest(r)) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(options) + } +} + +func authPasskeyLoginVerifyHandler(am *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + c, err := r.Cookie(passkeyChallengeCookieName) + if err != nil || strings.TrimSpace(c.Value) == "" { + http.Error(w, "passkey challenge missing", http.StatusBadRequest) + return + } + + challengeID := strings.TrimSpace(c.Value) + clearPasskeyChallengeCookie(w) + + sessionData := am.takePasskeySession("login:" + challengeID) + if sessionData == nil { + http.Error(w, "passkey challenge expired", http.StatusBadRequest) + return + } + + user, err := am.webAuthnUser() + if err != nil { + http.Error(w, "passkey user error", http.StatusInternalServerError) + return + } + + wa, err := am.webAuthnForRequest(r) + if err != nil { + appLogln("passkey login verify config failed:", err) + http.Error(w, "passkey login verify config failed: "+err.Error(), http.StatusBadRequest) + return + } + + credential, err := wa.FinishLogin(user, *sessionData, r) + if err != nil { + appLogln("passkey login verify failed:", err) + http.Error(w, "passkey login verify failed: "+err.Error(), http.StatusUnauthorized) + return + } + + credentialID := credentialIDString(credential.ID) + nowISO := time.Now().Format(time.RFC3339) + + am.confMu.Lock() + for i := range am.conf.Passkeys { + if string(am.conf.Passkeys[i].ID) == string(credential.ID) { + am.conf.Passkeys[i].Authenticator.SignCount = credential.Authenticator.SignCount + break + } + } + am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{ + CredentialID: credentialID, + LastUsedAt: nowISO, + }) + username := am.conf.Username + am.confMu.Unlock() + + go am.persistConfBestEffort() + + sid, err := newSessionID() + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + + now := time.Now() + s := &session{ + User: username, + Authed: true, + Pending2FA: false, + CreatedAt: now, + LastSeenAt: now, + ExpiresAt: now.Add(sessionTTL), + } + + am.sessMu.Lock() + am.sess[sid] = s + am.sessMu.Unlock() + + setSessionCookie(w, sid, isSecureRequest(r)) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 98d180b..7335f8d 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -3,23 +3,45 @@ package main import ( - "encoding/json" - "errors" "fmt" "net/url" "os" - "path/filepath" "sort" "strings" "time" ) type autoStartItem struct { - userKey string - url string - blind bool - source string // "watched" | "manual" | "resume" - force bool // true = ignoriert Autostart-Pause + Download-Limit + userKey string + url string + blind bool + source string // "watched" | "manual" | "resume" + force bool + ownerUserKey string // nur für manuell hinzugefügte Pending-Einträge +} + +var lastChaturbateDownloadLimitLogAt time.Time + +func logChaturbateAutoStartError(prefix string, modelURL string, err error) { + if err == nil { + return + } + + // Download-Limit nur selten loggen, weil der Autostart-Worker regelmäßig retryt. + if isDownloadLimitReachedError(err) { + now := time.Now() + + if !lastChaturbateDownloadLimitLogAt.IsZero() && + now.Sub(lastChaturbateDownloadLimitLogAt) < 60*time.Second { + return + } + + lastChaturbateDownloadLimitLogAt = now + appLogln(prefix, modelURL, err) + return + } + + appLogln(prefix, modelURL, err) } func normUser(s string) string { @@ -148,13 +170,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun } jobsMu.RUnlock() - // Job schon weg oder nicht mehr running -> nichts tun - if job == nil || status != JobRunning { - return - } - // echte Output-Datei vorhanden? - if out != "" { + // Wichtig: zuerst prüfen, bevor wir bei "nicht mehr running" zurückkehren. + // Ein Job kann sehr schnell in Postwork/Stopped/Failed wechseln. + if job != nil && out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 { // hidden blind-try wird jetzt sichtbar gemacht if hidden { @@ -175,6 +194,16 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun } } + // Job schon weg oder nicht mehr running, aber keine Datei entstanden. + // Für resume ist das wichtig: Der Pending-Eintrag wurde beim Start entfernt + // und muss bei Start ohne Output wiederhergestellt werden. + if job == nil || status != JobRunning { + if onNoOutput != nil { + onNoOutput() + } + return + } + time.Sleep(900 * time.Millisecond) } @@ -229,71 +258,6 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun } } -func clearAllPendingAutoStartOnStartup() error { - pendingAutoStartMu.Lock() - defer pendingAutoStartMu.Unlock() - - dir := filepath.Join("data", "pending-autostart") - - entries, err := os.ReadDir(dir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := strings.TrimSpace(entry.Name()) - if name == "" { - continue - } - if !strings.HasSuffix(strings.ToLower(name), ".json") { - continue - } - - path := filepath.Join(dir, name) - - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - return err - } - - var f pendingAutoStartFile - if err := json.Unmarshal(raw, &f); err != nil { - _ = os.Remove(path) - continue - } - - kept := make([]PendingAutoStartItem, 0, len(f.Items)) - for _, it := range f.Items { - if normalizePendingSourceServer(it.Source) == "resume" { - kept = append(kept, it) - } - } - - if len(kept) == 0 { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - continue - } - - if err := savePendingAutoStartItems(strings.TrimSuffix(name, filepath.Ext(name)), kept); err != nil { - return err - } - } - - return nil -} - // Startet watched+online(public) automatisch – unabhängig vom Frontend func startChaturbateAutoStartWorker(store *ModelStore) { if store == nil { @@ -573,6 +537,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) { show := normalizePendingShowServer(showByUser[it.userKey]) if it.blind { + // Offline-/Unknown-Blind-Probes nur für manuell hinzugefügte Models. + // Watched/Resume dürfen bei offline nicht in "Wartend" bleiben. + if it.source != "manual" { + continue + } + if show != "offline" && show != "unknown" { continue } @@ -590,20 +560,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued = nextQueued blindQueued := false - selectedBlindUser := hiddenProbeUser for _, it := range queue { if it.blind { blindQueued = true - if selectedBlindUser == "" { - selectedBlindUser = it.userKey - } break } } offlineCandidates := make([]autoStartItem, 0, len(watchedOrder)) - nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder)+len(resumeOrder)+len(runningByUser)) + nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) now := time.Now() resumePendingThisScan := map[string]bool{} @@ -631,14 +597,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) { img := strings.TrimSpace(imageByUser[user]) - nextPending = append(nextPending, PendingAutoStartItem{ + // Wichtig: + // Resume NICHT in nextPending speichern. + // nextPending wird später über saveWatchedPendingAutoStartItemsForProvider() + // gespeichert, und diese Funktion setzt Source hart auf "watched". + // Deshalb Resume separat in der globalen Pending-Datei speichern. + pendingAutoStartMu.Lock() + if err := saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", - }) + }); err != nil && verboseLogs() { + appLogln("⚠️ [autostart] save resume pending failed:", user, err) + } + pendingAutoStartMu.Unlock() resumePendingThisScan[user] = true @@ -679,12 +654,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) { }) queued[user] = true - case "private", "hidden", "away", "offline", "unknown": + case "private", "hidden", "away": if resumePendingThisScan[user] { continue } - nextPending = append(nextPending, PendingAutoStartItem{ + pendingAutoStartMu.Lock() + _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", @@ -692,6 +668,28 @@ func startChaturbateAutoStartWorker(store *ModelStore) { ImageURL: img, Source: "resume", }) + pendingAutoStartMu.Unlock() + + case "offline": + pendingAutoStartMu.Lock() + _ = removeResumePendingAutoStartItemForProvider("chaturbate", user) + pendingAutoStartMu.Unlock() + + case "unknown": + if resumePendingThisScan[user] { + continue + } + + pendingAutoStartMu.Lock() + _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ + ModelKey: user, + URL: u, + Mode: "wait_public", + CurrentShow: show, + ImageURL: img, + Source: "resume", + }) + pendingAutoStartMu.Unlock() } } @@ -730,7 +728,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued[user] = true case "private", "hidden", "away": - nextPending = append(nextPending, PendingAutoStartItem{ + // Wenn der User in diesem Scan gerade wegen private/hidden/away + // aus einem laufenden Download gestoppt wurde, existiert bereits + // ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag + // zusätzlich speichern. + if resumePendingThisScan[user] { + continue + } + + nextWatchedPending = append(nextWatchedPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", @@ -739,26 +745,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) { Source: "watched", }) - default: - if autostartPaused { - continue - } - if runningByUser[user] != nil { - continue - } - if queued[user] { - continue - } - if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { - continue - } + case "offline": + // Watched-Model ist wirklich offline: + // nicht als "Wartend" anzeigen und keinen Blind-Probe starten. + continue - offlineCandidates = append(offlineCandidates, autoStartItem{ - userKey: user, - url: u, - blind: true, - source: "watched", - }) + default: + // unknown nicht aggressiv in Wartend schieben. + // Wenn kein frischer Online-Status da ist, lieber nichts tun. + continue } } @@ -792,10 +787,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } queue = append(queue, autoStartItem{ - userKey: user, - url: u, - blind: false, - source: "manual", + userKey: user, + url: u, + blind: false, + source: "manual", + ownerUserKey: itm.OwnerUserKey, }) queued[user] = true @@ -818,10 +814,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } offlineCandidates = append(offlineCandidates, autoStartItem{ - userKey: user, - url: u, - blind: true, - source: "manual", + userKey: user, + url: u, + blind: true, + source: "manual", + ownerUserKey: itm.OwnerUserKey, }) } } @@ -848,39 +845,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queue = append(queue, it) queued[it.userKey] = true - selectedBlindUser = it.userKey probeCursor = (idx + 1) % n break } } - if selectedBlindUser != "" && !autostartPaused { - if m, ok := watchedByUser[selectedBlindUser]; ok { - u := resolveChaturbateURL(m) - if u != "" { - show := normalizePendingShowServer(showByUser[selectedBlindUser]) - img := strings.TrimSpace(imageByUser[selectedBlindUser]) - - nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli() - if t, ok := lastBlindTry[selectedBlindUser]; ok { - nextProbeAtMs = t.Add(blindRetryCooldown).UnixMilli() - } - - nextPending = append(nextPending, PendingAutoStartItem{ - ModelKey: selectedBlindUser, - URL: u, - Mode: "probe_retry", - NextProbeAtMs: nextProbeAtMs, - CurrentShow: show, - ImageURL: img, - Source: "watched", - }) - } - } - } - pendingAutoStartMu.Lock() - _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending) + _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending) pendingAutoStartMu.Unlock() case <-startTicker.C: @@ -908,7 +879,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if u == "" { continue } - showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) + showByUser[u] = normalizePendingShowServer(r.CurrentShow) } paused := isAutostartPaused() @@ -931,8 +902,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queue = append(queue[:startIdx], queue[startIdx+1:]...) delete(queued, it.userKey) - show := strings.TrimSpace(showByUser[it.userKey]) - isPublic := strings.Contains(show, "public") + show := normalizePendingShowServer(showByUser[it.userKey]) + isPublic := show == "public" + + // Normale queued Starts dürfen NICHT als Blind-Probe gestartet werden. + // Wenn ein resume/watched/manual Eintrag inzwischen nicht mehr public ist, + // bleibt er wartend bzw. wird im nächsten Scan korrekt neu bewertet. + if !it.blind && !isPublic { + if it.source == "resume" { + pendingAutoStartMu.Lock() + _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ + ModelKey: it.userKey, + URL: it.url, + Mode: "wait_public", + CurrentShow: show, + Source: "resume", + }) + pendingAutoStartMu.Unlock() + } + + continue + } // Nicht-public nur einzeln nacheinander prüfen. if it.blind && hasHiddenProbeRunningForProvider("chaturbate") { @@ -943,7 +933,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } - if !it.blind && isPublic { + if !it.blind { lastTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ @@ -953,7 +943,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { }) if err != nil { if verboseLogs() { - appLogln("❌ [autostart] start failed:", it.url, err) + logChaturbateAutoStartError("❌ [autostart] start failed:", it.url, err) } continue } @@ -1016,7 +1006,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) { }) if err != nil || job == nil { if verboseLogs() { - appLogln("❌ [autostart] blind start failed:", it.url, err) + if err != nil { + logChaturbateAutoStartError("❌ [autostart] blind start failed:", it.url, err) + } else { + appLogln("❌ [autostart] blind start failed:", it.url, "job is nil") + } } continue } @@ -1026,11 +1020,34 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } if it.source == "manual" { - go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() { - pendingAutoStartMu.Lock() - _ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey) - pendingAutoStartMu.Unlock() - }, nil) + go chaturbateAbortIfNoOutput( + job.ID, + outputProbeMax, + func() { + // Download läuft wirklich: manuellen Wartend-Eintrag entfernen. + pendingAutoStartMu.Lock() + _ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey) + pendingAutoStartMu.Unlock() + }, + func() { + // Blind-Probe hat keine Datei erzeugt: + // Jetzt erst als "Wartend" speichern. + owner := strings.TrimSpace(it.ownerUserKey) + if owner == "" { + return + } + + pendingAutoStartMu.Lock() + _ = saveManualPendingAutoStartItemForUser(owner, PendingAutoStartItem{ + ModelKey: it.userKey, + URL: it.url, + Mode: "wait_public", + CurrentShow: "offline", + Source: "manual", + }) + pendingAutoStartMu.Unlock() + }, + ) } else { go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil) } diff --git a/backend/cors.go b/backend/cors.go index 4b89986..52a7cf2 100644 --- a/backend/cors.go +++ b/backend/cors.go @@ -10,7 +10,12 @@ func withCORS(next http.Handler) http.Handler { origin := strings.TrimSpace(r.Header.Get("Origin")) // Dev-Origins erlauben - if origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173" { + if origin == "http://localhost:5173" || + origin == "http://127.0.0.1:5173" || + origin == "http://10.0.1.25:5173" || + origin == "https://localhost:5173" || + origin == "https://127.0.0.1:5173" || + origin == "https://10.0.1.25:5173" { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,HEAD,OPTIONS") diff --git a/backend/data/auth.json b/backend/data/auth.json index e863644..87482e1 100644 --- a/backend/data/auth.json +++ b/backend/data/auth.json @@ -2,5 +2,52 @@ "username": "admin", "passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC", "totpEnabled": true, - "totpSecret": "TIZUJ4A5SB2LOJCJN4T4MGOLBMDQ7NGG" + "totpSecret": "GYDAQKCCKJZUNGIB7D6OF53PPTXKCODR", + "totpInfo": { + "label": "Authenticator-App", + "configuredAt": "2026-05-08T11:56:30+02:00" + }, + "passkeyUserId": "t0t7ac1H0li39D0bcMKV-fjODVbbPjRE1NBrPNTpPYI", + "passkeys": [ + { + "id": "kGSnhcQdT2GlJIpJytlb2A==", + "publicKey": "pQECAyYgASFYIPvUFrzK7BVJeCgvGV0J+3bgIPLQk9dU/G5OH+wqGX5tIlggdYIWIqG/sAUL9uW0cd//8mKqwDg6IGHsJxCcjW99IWk=", + "attestationType": "none", + "attestationFormat": "none", + "transport": [ + "internal", + "hybrid" + ], + "flags": { + "userPresent": true, + "userVerified": true, + "backupEligible": true, + "backupState": true + }, + "authenticator": { + "AAGUID": "1UiCbnm020Cj2BERb36DSQ==", + "attachment": "platform" + }, + "attestation": { + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiallINXRhSW5yU0pvOVJKUnJJQkI3RnBKNDlXNVE0NTFuNVllbFd5WUFwMCIsIm9yaWdpbiI6Imh0dHBzOi8vbDE0cGJiazk1MTAwMDA2LnRlZ2Rzc2QuZGU6OTk5OSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=", + "clientDataHash": "AmcE/bKJzVx4+KYCV8UKWfRjxKDbb/fHg9M7e5QBzfs=", + "authenticatorData": "bc41HTx8SRDXvoeydjBR56tR4KxD4swPxYLi7Ztan1JdAAAAANVIgm55tNtAo9gREW9+g0kAEJBkp4XEHU9hpSSKScrZW9ilAQIDJiABIVgg+9QWvMrsFUl4KC8ZXQn7duAg8tCT11T8bk4f7CoZfm0iWCB1ghYiob+wBQv25bRx3//yYqrAODogYewnEJyNb30haQ==", + "publicKeyAlgorithm": -7, + "object": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUbc41HTx8SRDXvoeydjBR56tR4KxD4swPxYLi7Ztan1JdAAAAANVIgm55tNtAo9gREW9+g0kAEJBkp4XEHU9hpSSKScrZW9ilAQIDJiABIVgg+9QWvMrsFUl4KC8ZXQn7duAg8tCT11T8bk4f7CoZfm0iWCB1ghYiob+wBQv25bRx3//yYqrAODogYewnEJyNb30haQ==" + } + } + ], + "passkeyInfos": [ + { + "credentialId": "X1lPY4u7SJefAU3TGwSYBw", + "label": "Passkey X1lPY4…GwSYBw", + "createdAt": "2026-05-08T10:42:34+02:00" + }, + { + "credentialId": "kGSnhcQdT2GlJIpJytlb2A", + "label": "Passkey kGSnhc…ytlb2A", + "createdAt": "2026-05-08T12:30:03+02:00", + "lastUsedAt": "2026-05-08T12:30:09+02:00" + } + ] } diff --git a/backend/embed.go b/backend/embed.go index 37e828f..2885023 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -8,7 +8,7 @@ import ( "path/filepath" ) -//go:embed ml/*.py ml/*.json ai_server.py +//go:embed ml/*.py ml/*.json ai_server.py .env recorder-cert.pem recorder-key.pem var embeddedMLFiles embed.FS func trainingEmbeddedMLDir() (string, error) { @@ -74,3 +74,46 @@ func embeddedAIServerDir() (string, error) { return dir, nil } + +func embeddedDotEnvBytes() ([]byte, error) { + return embeddedMLFiles.ReadFile(".env") +} + +func embeddedTLSCertBytes() ([]byte, error) { + return embeddedMLFiles.ReadFile("recorder-cert.pem") +} + +func embeddedTLSKeyBytes() ([]byte, error) { + return embeddedMLFiles.ReadFile("recorder-key.pem") +} + +func embeddedTLSFilesDir() (string, string, error) { + dir := filepath.Join(os.TempDir(), "nsfwapp-tls") + + if err := os.MkdirAll(dir, 0700); err != nil { + return "", "", err + } + + certBytes, err := embeddedTLSCertBytes() + if err != nil { + return "", "", err + } + + keyBytes, err := embeddedTLSKeyBytes() + if err != nil { + return "", "", err + } + + certPath := filepath.Join(dir, "recorder-cert.pem") + keyPath := filepath.Join(dir, "recorder-key.pem") + + if err := os.WriteFile(certPath, certBytes, 0600); err != nil { + return "", "", err + } + + if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil { + return "", "", err + } + + return certPath, keyPath, nil +} diff --git a/backend/go.mod b/backend/go.mod index d08aaeb..95aa51c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,17 +5,20 @@ go 1.25.3 require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/getlantern/systray v1.2.2 + github.com/go-webauthn/webauthn v0.17.2 github.com/google/uuid v1.6.0 github.com/grafov/m3u8 v0.12.1 github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 github.com/pquerna/otp v1.5.0 github.com/r3labs/sse/v2 v2.10.0 github.com/yalue/onnxruntime_go v1.27.0 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.50.0 ) require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect @@ -24,18 +27,25 @@ require ( github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect ) @@ -45,6 +55,6 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 golang.org/x/image v0.37.0 - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 5acb24c..be1de44 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -9,6 +9,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= @@ -27,9 +29,21 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.2 h1:e9YtSZTVnxnMWFezXi6JvnqOSxmH4Er8QDHK2a/mM40= +github.com/go-webauthn/webauthn v0.17.2/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg= +github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= +github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= @@ -42,12 +56,16 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= @@ -70,23 +88,29 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U= github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -104,8 +128,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -131,8 +155,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -151,8 +175,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/backend/main.go b/backend/main.go index d67bb33..0d18891 100644 --- a/backend/main.go +++ b/backend/main.go @@ -875,7 +875,7 @@ func remuxTSToMP4WithProgress( "-nostats", "-progress", "pipe:1", "-hide_banner", - "-loglevel", "warning", + "-loglevel", "error", } args = append(args, ffmpegInputTol...) args = append(args, @@ -1677,7 +1677,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error { args := []string{ "-y", "-hide_banner", - "-loglevel", "warning", + "-loglevel", "error", } args = append(args, ffmpegInputTol...) args = append(args, diff --git a/backend/meta.go b/backend/meta.go index f1a7cbd..3b74c44 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -61,8 +61,6 @@ type previewMeta struct { } type analysisMeta struct { - AI *aiAnalysisMeta `json:"ai,omitempty"` - NSFW *aiAnalysisMeta `json:"nsfw,omitempty"` Highlights *aiAnalysisMeta `json:"highlights,omitempty"` } @@ -86,11 +84,6 @@ type postworkCompletedMeta struct { 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"` UpdatedAtUnix int64 `json:"updatedAtUnix"` @@ -99,7 +92,6 @@ type videoMeta struct { Media videoMediaMeta `json:"media"` Preview previewMeta `json:"preview,omitempty"` Analysis analysisMeta `json:"analysis,omitempty"` - Postwork postworkMeta `json:"postwork,omitempty"` Validation map[string]any `json:"validation,omitempty"` } @@ -391,40 +383,18 @@ func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool { if len(ai.Segments) > 0 { return true } + if ai.Rating != nil { + return true + } if ai.AnalyzedAtUnix > 0 { return true } return false } -func normalizeAIGoal(goal string) string { - goal = strings.ToLower(strings.TrimSpace(goal)) - if goal == "" { - return "nsfw" - } - return goal -} - func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta { - goal = normalizeAIGoal(goal) - - switch goal { - case "nsfw": - if hasAIAnalysisMeta(a.NSFW) { - return a.NSFW - } - case "highlights": - if hasAIAnalysisMeta(a.Highlights) { - return a.Highlights - } - } - - // Legacy-Fallback: alte meta.json hatte nur analysis.ai - if hasAIAnalysisMeta(a.AI) { - storedGoal := normalizeAIGoal(a.AI.Goal) - if storedGoal == goal { - return a.AI - } + if hasAIAnalysisMeta(a.Highlights) { + return a.Highlights } return nil @@ -442,7 +412,15 @@ func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { return u, true } -func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error { +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 } @@ -474,7 +452,6 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, if existing != nil { m.Preview = existing.Preview m.Analysis = existing.Analysis - m.Postwork = existing.Postwork m.Validation = existing.Validation if m.File.SourceURL == "" { @@ -494,7 +471,86 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, } } - buf, err := json.Marshal(m) + buf, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + buf = append(buf, '\n') + return atomicWriteFile(metaPath, buf) +} + +func writeVideoMetaAI( + metaPath string, + fi os.FileInfo, + dur float64, + w int, + h int, + fps float64, + sourceURL string, + ai *aiAnalysisMeta, +) error { + if strings.TrimSpace(metaPath) == "" || dur <= 0 { + return nil + } + + var existing *videoMeta + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { + existing = old + } + + m := videoMeta{ + 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.Preview = existing.Preview + m.Validation = existing.Validation + + 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.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.DurationSeconds <= 0 { + m.Media.DurationSeconds = existing.Media.DurationSeconds + } + + // Wichtig: + // analysis wird NICHT aus existing übernommen. + // Dadurch verschwinden alte analysis.ai / analysis.nsfw beim nächsten Schreiben. + } + + if hasAIAnalysisMeta(ai) { + ai.Goal = "highlights" + m.Analysis.Highlights = ai + } + + buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err } @@ -538,7 +594,6 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64 if existing != nil { m.Analysis = existing.Analysis - m.Postwork = existing.Postwork if existing.Preview.Sprite != nil && m.Preview.Sprite == nil { m.Preview.Sprite = existing.Preview.Sprite @@ -618,7 +673,6 @@ func writeVideoMetaWithPreviewClipsAndSprite( if existing != nil { m.Analysis = existing.Analysis - m.Postwork = existing.Postwork m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite) @@ -774,103 +828,6 @@ func sanitizeID(id string) (string, error) { return id, nil } -func writeVideoMetaAI( - metaPath string, - fi os.FileInfo, - dur float64, - w int, - h int, - fps float64, - sourceURL string, - ai *aiAnalysisMeta, -) error { - if strings.TrimSpace(metaPath) == "" || dur <= 0 { - return nil - } - - var existing *videoMeta - if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { - existing = old - } - - m := videoMeta{ - 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.Preview = existing.Preview - m.Analysis = existing.Analysis - m.Postwork = existing.Postwork - m.Validation = existing.Validation - - 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.File.SourceURL == "" { - m.File.SourceURL = existing.File.SourceURL - } - if m.Media.DurationSeconds <= 0 { - m.Media.DurationSeconds = existing.Media.DurationSeconds - } - } - - if hasAIAnalysisMeta(ai) { - goal := normalizeAIGoal(ai.Goal) - ai.Goal = goal - - switch goal { - case "nsfw": - m.Analysis.NSFW = ai - - // Legacy/default für bestehende UI: RatingOverlay liest aktuell analysis.ai. - m.Analysis.AI = ai - - case "highlights": - m.Analysis.Highlights = ai - - // Wichtig: analysis.ai NICHT mit highlights überschreiben, - // sonst verschwindet dein NSFW-Rating/Stern. - if !hasAIAnalysisMeta(m.Analysis.AI) { - m.Analysis.AI = ai - } - - default: - m.Analysis.AI = ai - } - } - - buf, err := json.MarshalIndent(m, "", " ") - if err != nil { - return err - } - buf = append(buf, '\n') - return atomicWriteFile(metaPath, buf) -} - func writeVideoAIForFile( ctx context.Context, fullPath string, @@ -924,94 +881,6 @@ func writeVideoAIForFile( ) } -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 readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { @@ -1030,82 +899,10 @@ func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) return nil, false } - goal = normalizeAIGoal(goal) - - keys := []string{goal} - - // Legacy-Fallback: alte meta.json hatte nur analysis.ai. - // Aber nur akzeptieren, wenn goal wirklich passt. - keys = append(keys, "ai") - - for _, key := range keys { - ai, ok := rawAnalysis[key].(map[string]any) - if !ok || ai == nil { - continue - } - - storedGoal := normalizeAIGoal(fmt.Sprint(ai["goal"])) - if storedGoal != goal { - continue - } - - return ai, true + // Neuer einziger Zweig. + if highlights, ok := rawAnalysis["highlights"].(map[string]any); ok && highlights != nil { + return highlights, true } return nil, false } - -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 appErrorf("fullPath fehlt") - } - - fi, err := os.Stat(fullPath) - if err != nil || fi == nil || fi.IsDir() { - return appErrorf("datei nicht gefunden") - } - - stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) - assetID := stripHotPrefix(strings.TrimSpace(stem)) - if assetID == "" { - return appErrorf("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 appErrorf("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/ml/detection_labels.json b/backend/ml/detection_labels.json index 9ffa529..ae2c685 100644 --- a/backend/ml/detection_labels.json +++ b/backend/ml/detection_labels.json @@ -20,8 +20,7 @@ "blowjob", "toy_play", "fingering", - "69", - "other" + "69" ], "bodyParts": [ "anus", diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index df11abe..209e49e 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -122,7 +122,7 @@ def main(): emit_progress( "detector", 0.04 + 0.90 * ((epoch - 1) / max(1, total)), - f"Object Detector trainiert… Epoche {epoch}/{total}", + f"Object Detector trainiert…", epoch=epoch, epochs=total, trainSamples=train_count, @@ -140,7 +140,7 @@ def main(): emit_progress( "detector", 0.04 + 0.90 * (epoch / max(1, total)), - f"Object Detector trainiert… Epoche {epoch}/{total}", + f"Object Detector trainiert…", epoch=epoch, epochs=total, trainSamples=train_count, @@ -173,7 +173,7 @@ def main(): emit_progress( "detector", 0.04 + 0.90 * (epoch / max(1, total)), - f"Object Detector validiert… Epoche {epoch}/{total}", + f"Object Detector validiert…", epoch=epoch, epochs=total, mAP50=map50, diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index 36a552d..e6edd17 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -101,7 +101,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { const scanInterval = 1 * time.Second const startInterval = 2 * time.Second const cooldown = 2 * time.Minute - const outputProbeMax = 20 * time.Second + const outputProbeMax = 30 * time.Second queue := make([]autoStartItem, 0, 64) queued := map[string]bool{} @@ -428,13 +428,13 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { } if it.source == "manual" { - go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() { + go mfcKeepIfOutputGrows(job.ID, outputProbeMax, func() { pendingAutoStartMu.Lock() _ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey) pendingAutoStartMu.Unlock() }) } else { - go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil) + go mfcKeepIfOutputGrows(job.ID, outputProbeMax, nil) } } } @@ -460,43 +460,66 @@ func isJobRunningForURL(u string) bool { return false } -// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen. -// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht. -func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) { +// Wenn die Output-Datei innerhalb maxWait sichtbar wächst, behalten wir den Job. +// Wenn sie nicht wächst, stoppen wir den Hidden-Probe und löschen die angelegte Datei. +// Hintergrund: MFC hat keine saubere API. Deshalb wird blind gestartet und anhand echter Dateiaktivität entschieden. +func mfcKeepIfOutputGrows(jobID string, maxWait time.Duration, onKeep func()) { deadline := time.Now().Add(maxWait) + var lastSize int64 = -1 + var sawFile bool + for time.Now().Before(deadline) { jobsMu.RLock() job := jobs[jobID] status := JobStatus("") out := "" + hidden := false + if job != nil { status = job.Status out = strings.TrimSpace(job.Output) + hidden = job.Hidden } jobsMu.RUnlock() - // Job schon weg oder nicht mehr running -> nichts tun + // Job schon weg oder nicht mehr running -> nichts tun. if job == nil || status != JobRunning { return } - // Output schon da? if out != "" { - if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 { - if onOutput != nil { - onOutput() + if fi, err := os.Stat(out); err == nil && !fi.IsDir() { + size := fi.Size() + sawFile = true + + // Erst behalten, wenn die Datei wirklich wächst. + if lastSize >= 0 && size > lastSize { + if onKeep != nil { + onKeep() + } + + // Hidden-Probe wird zu normal sichtbarem Recording. + if hidden { + jobsMu.Lock() + if j := jobs[jobID]; j != nil && j.Status == JobRunning { + j.Hidden = false + } + jobsMu.Unlock() + } + + publishJob(jobID) + return } - publishJob(jobID) - return + lastSize = size } } - time.Sleep(900 * time.Millisecond) + time.Sleep(1 * time.Second) } - // nach Wartezeit immer noch keine Datei => stoppen + löschen + // Nach Wartezeit keine wachsende Datei => stoppen + löschen. jobsMu.Lock() job := jobs[jobID] if job == nil || job.Status != JobRunning { @@ -512,6 +535,7 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) { recordCancel := job.cancel out := strings.TrimSpace(job.Output) + wasVisible := !job.Hidden jobsMu.Unlock() @@ -525,20 +549,24 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) { recordCancel() } - // 0-Byte Datei ggf. wegwerfen + // Blind-Probe-Datei entfernen, auch wenn sie schon ein paar Bytes hat, + // denn sie ist nicht gewachsen und gilt damit als Fehlstart. if out != "" { - if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 { + if fi, err := os.Stat(out); err == nil && !fi.IsDir() { _ = os.Remove(out) } } jobsMu.Lock() j := jobs[jobID] - wasVisible := (j != nil && !j.Hidden) delete(jobs, jobID) jobsMu.Unlock() - if wasVisible { + if wasVisible && j != nil { publishJobRemove(j) } + + if sawFile { + appLogln("🧹 [mfc autostart] Probe ohne Wachstum entfernt:", jobID) + } } diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go index 2470b31..fb373d1 100644 --- a/backend/pending_autostart.go +++ b/backend/pending_autostart.go @@ -18,11 +18,13 @@ import ( type PendingAutoStartItem struct { ModelKey string `json:"modelKey"` URL string `json:"url"` - Mode string `json:"mode,omitempty"` // wait_public | probe_retry - NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry - CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown + Mode string `json:"mode,omitempty"` + NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` + CurrentShow string `json:"currentShow,omitempty"` ImageURL string `json:"imageUrl,omitempty"` - Source string `json:"source,omitempty"` // manual | watched + Source string `json:"source,omitempty"` + + OwnerUserKey string `json:"-"` } type PendingAutoStartResponse struct { @@ -198,6 +200,18 @@ func pendingAutoStartFilePath(userKey string) string { return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json") } +func clearAllPendingAutoStartOnStartup() error { + pendingAutoStartMu.Lock() + defer pendingAutoStartMu.Unlock() + + dir := filepath.Join("data", "pending-autostart") + + if err := os.RemoveAll(dir); err != nil { + return err + } + return nil +} + func listPendingAutoStartUserKeys() ([]string, error) { dir := filepath.Join("data", "pending-autostart") @@ -266,6 +280,9 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS if pendingProviderFromURL(it.URL) != provider { continue } + + it.OwnerUserKey = userKey + out = append(out, it) } } @@ -273,6 +290,44 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS return out, nil } +func saveManualPendingAutoStartItemForUser(userKey string, item PendingAutoStartItem) error { + userKey = strings.ToLower(strings.TrimSpace(userKey)) + if userKey == "" { + return errors.New("missing userKey") + } + + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + return err + } + + item.ModelKey = strings.ToLower(strings.TrimSpace(item.ModelKey)) + item.URL = strings.TrimSpace(item.URL) + item.Mode = normalizePendingModeServer(item.Mode) + item.CurrentShow = normalizePendingShowServer(item.CurrentShow) + item.ImageURL = strings.TrimSpace(item.ImageURL) + item.Source = "manual" + + if item.ModelKey == "" || item.URL == "" { + return nil + } + + replaced := false + for i := range items { + if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == item.ModelKey { + items[i] = item + replaced = true + break + } + } + + if !replaced { + items = append(items, item) + } + + return savePendingAutoStartItems(userKey, items) +} + func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error { provider = strings.ToLower(strings.TrimSpace(provider)) modelKey = strings.ToLower(strings.TrimSpace(modelKey)) diff --git a/backend/rating.go b/backend/rating.go index f44ae13..edfbb65 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -21,6 +21,17 @@ type aiRatingMeta struct { AvgConfidence float64 `json:"avgConfidence"` } +const ( + // Normales Zusammenziehen sehr kurzer Pausen. + ratingMaxSilentGapSec = 6.5 + + // Größere Lücken dürfen überbrückt werden, + // wenn beide Seiten stark genug / thematisch ähnlich sind. + ratingBridgeStrongGapSec = 35.0 + + ratingMinSegmentDurationSec = 2.5 +) + func ratingClamp01(v float64) float64 { if v < 0 { return 0 @@ -53,60 +64,22 @@ func ratingEffectiveDurationSeconds(seconds float64) float64 { return 0 } - // Lange Segmente zählen weiter, aber mit abnehmendem Zusatznutzen. - // Dadurch kippen falsche oder zu breite Merges nicht sofort auf 5 Sterne. - const kneeSeconds = 24.0 + // Lange Segmente zählen, aber nicht linear unendlich stark. + const kneeSeconds = 26.0 return kneeSeconds * math.Log1p(seconds/kneeSeconds) } -func segmentSeverityWeight(label string) float64 { +func normalizeSegmentLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" { - return 0.50 - } - // ------------------------- - // Kombi-Highlights - // ------------------------- - if strings.HasPrefix(label, "combo:") { - return comboSegmentSeverityWeight(label) - } + label = strings.TrimPrefix(label, "detector:") + label = strings.TrimPrefix(label, "body:") + label = strings.TrimPrefix(label, "object:") + label = strings.TrimPrefix(label, "clothing:") + label = strings.TrimPrefix(label, "position:") + label = strings.TrimPrefix(label, "person:") - // ------------------------- - // Sexpositionen - // ------------------------- - if strings.HasPrefix(label, "position:") { - pos := strings.TrimPrefix(label, "position:") - return positionSeverityWeight(pos) - } - - // ------------------------- - // Body / Objects / Clothing - // ------------------------- - if strings.HasPrefix(label, "body:") { - body := strings.TrimPrefix(label, "body:") - return bodyPartSeverityWeight(body) - } - - if strings.HasPrefix(label, "object:") { - obj := strings.TrimPrefix(label, "object:") - return objectSeverityWeight(obj) - } - - if strings.HasPrefix(label, "clothing:") { - clothing := strings.TrimPrefix(label, "clothing:") - return clothingSeverityWeight(clothing) - } - - if strings.HasPrefix(label, "detector:") { - det := strings.TrimPrefix(label, "detector:") - return detectorSeverityWeight(det) - } - - // ------------------------- - // Direkte YOLO-Labels - // ------------------------- - return detectorSeverityWeight(label) + return strings.TrimSpace(label) } func isPersonSegmentLabel(label string) bool { @@ -117,12 +90,13 @@ func isPersonSegmentLabel(label string) bool { label = strings.TrimPrefix(label, "object:") label = strings.TrimPrefix(label, "clothing:") label = strings.TrimPrefix(label, "position:") + label = strings.TrimPrefix(label, "person:") switch label { case "person", + "person_unknown", "person_male", "person_female", - "person_unknown", "male_person", "female_person", "people_male", @@ -133,65 +107,56 @@ func isPersonSegmentLabel(label string) bool { } } -func detectorSeverityWeight(label string) float64 { +func isKnownPositionLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) switch label { - // Personen werden vor dem Rating komplett herausgefiltert. - // Dieser Fallback bleibt nur für alte Pfade. - case "person", "person_male", "person_female", "person_unknown", "people_male", "people_female": - return 0.00 - - // bodyParts aus detecton_labels.json - case "pussy", "vulva": - return 1.00 - case "penis": - return 0.95 - case "anus": - return 0.90 - case "breasts": - return 0.80 - case "ass", "buttocks": - return 0.65 - case "tongue": - return 0.45 - - // objects aus detecton_labels.json - case "dildo", "vibrator", "strapon", "buttplug": - return 0.85 - case "handcuffs", "blindfold", "collar": - return 0.55 - case "shower": - return 0.40 - case "towel": - return 0.25 - - // clothing aus detecton_labels.json - case "lingerie": - return 0.60 - case "panties", "bra": - return 0.55 - case "bikini": - return 0.35 - case "stockings", "heels": - return 0.35 - case "skirt", "dress", "hotpants", "croptop": - return 0.30 - - // optionale alte/alias Labels, falls noch in alten Metas vorhanden - case "female_genitalia_exposed": - return 1.00 - case "male_genitalia_exposed": - return 0.95 - case "anus_exposed": - return 0.90 - case "female_breast_exposed", "breast_exposed": - return 0.80 - case "buttocks_exposed": - return 0.65 - + case "unknown", + "missionary", + "doggy", + "doggystyle", + "cowgirl", + "reverse_cowgirl", + "cunnilingus", + "prone_bone", + "standing", + "standing_doggy", + "spooning", + "sitting", + "facesitting", + "handjob", + "blowjob", + "toy_play", + "fingering", + "69": + return true default: - return 0.50 + return false + } +} + +func positionSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + case "doggy", "doggystyle", "standing_doggy": + return 1.00 + case "cowgirl", "reverse_cowgirl": + return 0.98 + case "missionary", "prone_bone": + return 0.95 + case "blowjob", "cunnilingus", "69", "facesitting": + return 0.94 + case "toy_play": + return 0.88 + case "handjob", "fingering": + return 0.84 + case "spooning": + return 0.78 + case "standing", "sitting": + return 0.42 + default: + return 0.00 } } @@ -208,15 +173,11 @@ func bodyPartSeverityWeight(label string) float64 { case "breasts": return 0.80 case "ass", "buttocks": - return 0.65 + return 0.66 case "tongue": - return 0.45 - case "mouth", "face": - return 0.45 - case "hands": - return 0.35 + return 0.46 default: - return 0.50 + return 0.00 } } @@ -224,16 +185,16 @@ func objectSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { - case "dildo", "vibrator", "strapon", "buttplug", "sex_toy": - return 0.85 + case "dildo", "vibrator", "strapon", "buttplug": + return 0.86 case "handcuffs", "blindfold", "collar": - return 0.55 + return 0.58 case "shower": - return 0.40 + return 0.42 case "towel": - return 0.25 + return 0.28 default: - return 0.45 + return 0.00 } } @@ -241,59 +202,51 @@ func clothingSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { - case "nude", "naked": - return 0.90 case "lingerie": - return 0.60 - case "panties", "bra", "underwear": - return 0.55 - case "bikini", "swimwear": - return 0.35 + return 0.64 + case "panties", "bra": + return 0.58 + case "bikini": + return 0.42 case "stockings", "heels": - return 0.35 + return 0.40 case "skirt", "dress", "hotpants", "croptop": - return 0.30 + return 0.34 default: - return 0.30 + return 0.00 } } -func positionSeverityWeight(label string) float64 { +func personContextWeight(label string) float64 { + if !isPersonSegmentLabel(label) { + return 0 + } + + // Personen sind Kontext, aber nie alleiniger Rating-Treiber. + return 0.18 +} + +func detectorSeverityWeight(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) - switch label { - // sehr explizit / klar sexuelle Handlung - case "doggy", "doggystyle", "standing_doggy": - return 1.00 - case "cowgirl", "reverse_cowgirl": - return 0.98 - case "missionary": - return 0.95 - case "prone_bone": - return 0.95 - case "blowjob", "cunnilingus", "oral", "69", "facesitting": - return 0.94 - - // explizit, aber etwas niedriger - case "toy_play": - return 0.88 - case "handjob", "fingering": - return 0.84 - case "spooning": - return 0.78 - - // Pose/Kontext, aber nicht stark genug alleine - case "standing", "sitting": - return 0.42 - case "other": - return 0.45 - - case "unknown", "": - return 0.00 - - default: - return 0.00 + if isPersonSegmentLabel(label) { + return personContextWeight(label) } + + if w := bodyPartSeverityWeight(label); w > 0 { + return w + } + if w := objectSeverityWeight(label); w > 0 { + return w + } + if w := clothingSeverityWeight(label); w > 0 { + return w + } + if isKnownPositionLabel(label) { + return positionSeverityWeight(label) + } + + return 0.00 } type ratingSignalSet struct { @@ -301,55 +254,102 @@ type ratingSignalSet struct { Body float64 Object float64 Clothing float64 + Person float64 HasPosition bool HasBody bool HasObject bool HasClothing bool + HasPerson bool } -func isKnownPositionLabel(label string) bool { +func normalizeRatingSignalLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) - - switch label { - case "doggy", - "doggystyle", - "standing_doggy", - "cowgirl", - "reverse_cowgirl", - "missionary", - "prone_bone", - "blowjob", - "cunnilingus", - "oral", - "69", - "facesitting", - "toy_play", - "handjob", - "fingering", - "spooning", - "standing", - "sitting", - "other": - return true - default: - return false + if label == "" || label == "unknown" { + return "" } + + if strings.HasPrefix(label, "combo:") { + return label + } + + if strings.HasPrefix(label, "detector:") { + return normalizeRatingSignalLabel(strings.TrimPrefix(label, "detector:")) + } + + if strings.HasPrefix(label, "position:") { + raw := strings.TrimPrefix(label, "position:") + if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 { + return "position:" + raw + } + return "" + } + + if strings.HasPrefix(label, "body:") { + raw := strings.TrimPrefix(label, "body:") + if bodyPartSeverityWeight(raw) > 0 { + return "body:" + raw + } + return "" + } + + if strings.HasPrefix(label, "object:") { + raw := strings.TrimPrefix(label, "object:") + if objectSeverityWeight(raw) > 0 { + return "object:" + raw + } + return "" + } + + if strings.HasPrefix(label, "clothing:") { + raw := strings.TrimPrefix(label, "clothing:") + if clothingSeverityWeight(raw) > 0 { + return "clothing:" + raw + } + return "" + } + + if strings.HasPrefix(label, "person:") { + raw := strings.TrimPrefix(label, "person:") + if isPersonSegmentLabel(raw) { + return "person:" + normalizeSegmentLabel(raw) + } + return "" + } + + raw := normalizeSegmentLabel(label) + if raw == "" || raw == "unknown" { + return "" + } + + if isPersonSegmentLabel(raw) { + return "person:" + raw + } + if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 { + return "position:" + raw + } + if bodyPartSeverityWeight(raw) > 0 { + return "body:" + raw + } + if objectSeverityWeight(raw) > 0 { + return "object:" + raw + } + if clothingSeverityWeight(raw) > 0 { + return "clothing:" + raw + } + + return "" } func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { - label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || isPersonSegmentLabel(label) { + label = normalizeRatingSignalLabel(label) + if label == "" { return } switch { case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") - if !isKnownPositionLabel(raw) { - return - } - w := positionSeverityWeight(raw) if w > set.Position { set.Position = w @@ -359,7 +359,8 @@ func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { } case strings.HasPrefix(label, "body:"): - w := bodyPartSeverityWeight(strings.TrimPrefix(label, "body:")) + raw := strings.TrimPrefix(label, "body:") + w := bodyPartSeverityWeight(raw) if w > set.Body { set.Body = w } @@ -368,7 +369,8 @@ func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { } case strings.HasPrefix(label, "object:"): - w := objectSeverityWeight(strings.TrimPrefix(label, "object:")) + raw := strings.TrimPrefix(label, "object:") + w := objectSeverityWeight(raw) if w > set.Object { set.Object = w } @@ -377,7 +379,8 @@ func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { } case strings.HasPrefix(label, "clothing:"): - w := clothingSeverityWeight(strings.TrimPrefix(label, "clothing:")) + raw := strings.TrimPrefix(label, "clothing:") + w := clothingSeverityWeight(raw) if w > set.Clothing { set.Clothing = w } @@ -385,34 +388,14 @@ func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { set.HasClothing = true } - case strings.HasPrefix(label, "detector:"): - raw := strings.TrimPrefix(label, "detector:") - - // Detector-Labels sind meistens Body/Object/Clothing, nicht Position. - switch { - case bodyPartSeverityWeight(raw) >= 0.65: - ratingSignalSetAddLabel(set, "body:"+raw) - case objectSeverityWeight(raw) >= 0.50: - ratingSignalSetAddLabel(set, "object:"+raw) - case clothingSeverityWeight(raw) >= 0.50: - ratingSignalSetAddLabel(set, "clothing:"+raw) - case isKnownPositionLabel(raw): - ratingSignalSetAddLabel(set, "position:"+raw) + case strings.HasPrefix(label, "person:"): + raw := strings.TrimPrefix(label, "person:") + w := personContextWeight(raw) + if w > set.Person { + set.Person = w } - - default: - // Direkte alte YOLO-Labels sinnvoll einsortieren. - // Wichtig: Position zuletzt prüfen, sonst greift positionSeverityWeight(default=0.60) - // für fast alles. - switch { - case bodyPartSeverityWeight(label) >= 0.65: - ratingSignalSetAddLabel(set, "body:"+label) - case objectSeverityWeight(label) >= 0.50: - ratingSignalSetAddLabel(set, "object:"+label) - case clothingSeverityWeight(label) >= 0.50: - ratingSignalSetAddLabel(set, "clothing:"+label) - case isKnownPositionLabel(label): - ratingSignalSetAddLabel(set, "position:"+label) + if w > 0 { + set.HasPerson = true } } } @@ -434,294 +417,577 @@ func ratingSignalSetFromLabel(label string) ratingSignalSet { return set } +func ratingSignalSetHasInterestingContent(set ratingSignalSet) bool { + return set.HasPosition || set.HasBody || set.HasObject || set.HasClothing +} + func contextualSegmentSeverityWeight(label string) float64 { set := ratingSignalSetFromLabel(label) - hasAny := - set.HasPosition || - set.HasBody || - set.HasObject || - set.HasClothing - - if !hasAny { + if !ratingSignalSetHasInterestingContent(set) { return 0 } var score float64 if set.HasPosition { - // Position ist der Hauptanker. score = - 0.72*set.Position + - 0.14*set.Body + - 0.09*set.Object + - 0.05*set.Clothing + 0.66*set.Position + + 0.17*set.Body + + 0.10*set.Object + + 0.05*set.Clothing + + 0.02*set.Person - // Echte Kombi-Boni. if set.HasBody { - score += 0.04 + score += 0.055 } if set.HasObject { - score += 0.035 + score += 0.040 } if set.HasClothing { + score += 0.020 + } + if set.HasPerson { score += 0.015 } - // Schwache Positionslabels wie standing/sitting sollen ohne Kontext niedrig bleiben. - if set.Position < 0.60 && !set.HasBody && !set.HasObject { - score = math.Min(score, 0.45) + // Standing/Sitting ohne Kontext bleibt schwach. + if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing { + score = math.Min(score, 0.42) } return ratingClamp01(score) } - // Ohne Position: Kontext darf zählen, aber gedeckelt. score = - 0.58*set.Body + - 0.28*set.Object + - 0.14*set.Clothing + 0.54*set.Body + + 0.30*set.Object + + 0.14*set.Clothing + + 0.02*set.Person if set.HasBody && set.HasObject { - score += 0.08 + score += 0.09 } if set.HasBody && set.HasClothing { + score += 0.04 + } + if set.HasObject && set.HasClothing { score += 0.03 } - - // Kleidung alleine niemals stark bewerten. - if set.HasClothing && !set.HasBody && !set.HasObject { - score = math.Min(score, 0.32) + if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) { + score += 0.015 } - // Keine Position => nicht höher als "mittel-hoch". - score = math.Min(score, 0.68) + // Kleidung alleine soll interessant sein können, aber nicht stark. + if set.HasClothing && !set.HasBody && !set.HasObject { + score = math.Min(score, 0.38) + } + + // Ohne Position nicht zu aggressiv. + score = math.Min(score, 0.72) return ratingClamp01(score) } func comboSegmentSeverityWeight(label string) float64 { - raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(label)), "combo:") - if raw == "" { - return 0.00 + set := ratingSignalSetFromLabel(label) + return contextualSegmentSeverityWeightFromSet(set) +} + +func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 { + if !ratingSignalSetHasInterestingContent(set) { + return 0 } - parts := strings.Split(raw, "+") + var labelParts []string - var weights []float64 - hasPersonContext := false + if set.HasPosition { + labelParts = append(labelParts, "position:x") + } + if set.HasBody { + labelParts = append(labelParts, "body:x") + } + if set.HasObject { + labelParts = append(labelParts, "object:x") + } + if set.HasClothing { + labelParts = append(labelParts, "clothing:x") + } + if set.HasPerson { + labelParts = append(labelParts, "person:x") + } + + _ = labelParts + + var score float64 + + if set.HasPosition { + score = + 0.66*set.Position + + 0.17*set.Body + + 0.10*set.Object + + 0.05*set.Clothing + + 0.02*set.Person + + if set.HasBody { + score += 0.055 + } + if set.HasObject { + score += 0.040 + } + if set.HasClothing { + score += 0.020 + } + if set.HasPerson { + score += 0.015 + } + + if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing { + score = math.Min(score, 0.42) + } + + return ratingClamp01(score) + } + + score = + 0.54*set.Body + + 0.30*set.Object + + 0.14*set.Clothing + + 0.02*set.Person + + if set.HasBody && set.HasObject { + score += 0.09 + } + if set.HasBody && set.HasClothing { + score += 0.04 + } + if set.HasObject && set.HasClothing { + score += 0.03 + } + if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) { + score += 0.015 + } + + if set.HasClothing && !set.HasBody && !set.HasObject { + score = math.Min(score, 0.38) + } + + score = math.Min(score, 0.72) + + return ratingClamp01(score) +} + +func segmentSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" { + return 0 + } + + if strings.HasPrefix(label, "combo:") { + return comboSegmentSeverityWeight(label) + } + + normalized := normalizeRatingSignalLabel(label) + if normalized == "" { + return 0 + } + + set := ratingSignalSetFromLabel(normalized) + sev := contextualSegmentSeverityWeightFromSet(set) + if sev > 0 { + return sev + } + + raw := normalizeSegmentLabel(normalized) + if raw == "" { + return 0 + } + + return detectorSeverityWeight(raw) +} + +func ratingSegmentSeverity(label string) float64 { + return segmentSeverityWeight(label) +} + +func ratingSignalPriority(label string) int { + label = normalizeRatingSignalLabel(label) + + switch { + case strings.HasPrefix(label, "position:"): + return 100 + case strings.HasPrefix(label, "body:"): + return 80 + case strings.HasPrefix(label, "object:"): + return 60 + case strings.HasPrefix(label, "clothing:"): + return 40 + case strings.HasPrefix(label, "person:"): + return 20 + default: + return 0 + } +} + +func buildRatingComboLabel(labels map[string]bool) string { + if len(labels) == 0 { + return "" + } + + parts := make([]string, 0, len(labels)) + set := ratingSignalSet{} + + for label := range labels { + normalized := normalizeRatingSignalLabel(label) + if normalized == "" { + continue + } + + ratingSignalSetAddLabel(&set, normalized) + parts = append(parts, normalized) + } + + if !ratingSignalSetHasInterestingContent(set) { + return "" + } + + sort.SliceStable(parts, func(i, j int) bool { + pi := ratingSignalPriority(parts[i]) + pj := ratingSignalPriority(parts[j]) + + if pi != pj { + return pi > pj + } + + wi := ratingSegmentSeverity(parts[i]) + wj := ratingSegmentSeverity(parts[j]) + + if wi != wj { + return wi > wj + } + + return parts[i] < parts[j] + }) + + deduped := make([]string, 0, len(parts)) + seen := map[string]bool{} for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { + if part == "" || seen[part] { continue } - - if isPersonSegmentLabel(part) { - hasPersonContext = true - continue - } - - weight := 0.0 - - switch { - case strings.HasPrefix(part, "position:"): - weight = positionSeverityWeight(strings.TrimPrefix(part, "position:")) - - case strings.HasPrefix(part, "body:"): - weight = bodyPartSeverityWeight(strings.TrimPrefix(part, "body:")) - - case strings.HasPrefix(part, "object:"): - weight = objectSeverityWeight(strings.TrimPrefix(part, "object:")) - - case strings.HasPrefix(part, "clothing:"): - weight = clothingSeverityWeight(strings.TrimPrefix(part, "clothing:")) - - case strings.HasPrefix(part, "detector:"): - det := strings.TrimPrefix(part, "detector:") - if isPersonSegmentLabel(det) { - hasPersonContext = true - continue - } - weight = detectorSeverityWeight(det) - - default: - if isPersonSegmentLabel(part) { - hasPersonContext = true - continue - } - weight = detectorSeverityWeight(part) - } - - if weight > 0 { - weights = append(weights, weight) - } + seen[part] = true + deduped = append(deduped, part) } - // Nur Person in der Combo => kein Rating-/Highlight-Gewicht. - if len(weights) == 0 { - return 0.00 + if len(deduped) == 0 { + return "" } - var sum float64 - var maxWeight float64 - - for _, weight := range weights { - sum += weight - if weight > maxWeight { - maxWeight = weight - } + if len(deduped) == 1 { + // Person alleine wird oben schon verhindert. + return deduped[0] } - avg := sum / float64(len(weights)) - - // Kombi = stärkstes Signal plus leichter Kontext-Boost. - combined := 0.70*maxWeight + 0.30*avg - - // Person macht das Highlight verständlicher, soll aber nicht stark boosten. - if hasPersonContext { - combined += 0.03 - } - - // Echte Combos sollen sichtbar relevant bleiben, - // aber nur wenn mindestens ein Nicht-Personen-Signal existiert. - if combined < 0.60 { - combined = 0.60 - } - if combined > 1.00 { - combined = 1.00 - } - - return combined + return "combo:" + strings.Join(deduped, "+") } -func normalizeSegmentLabel(label string) string { +func addRatingLabelsFromSegment(labels map[string]bool, label string) { label = strings.ToLower(strings.TrimSpace(label)) + if label == "" { + return + } - label = strings.TrimPrefix(label, "detector:") - label = strings.TrimPrefix(label, "body:") - label = strings.TrimPrefix(label, "object:") - label = strings.TrimPrefix(label, "clothing:") - label = strings.TrimPrefix(label, "position:") + if strings.HasPrefix(label, "combo:") { + raw := strings.TrimPrefix(label, "combo:") + for _, part := range strings.Split(raw, "+") { + addRatingLabelsFromSegment(labels, part) + } + return + } - return strings.TrimSpace(label) + normalized := normalizeRatingSignalLabel(label) + if normalized == "" { + return + } + + labels[normalized] = true } -func mergeAdjacentAISegments(segments []aiSegmentMeta, maxGapSec float64) []aiSegmentMeta { +func ratingSignalSetFromLabels(labels map[string]bool) ratingSignalSet { + var set ratingSignalSet + + for label := range labels { + ratingSignalSetAddLabel(&set, label) + } + + return set +} + +func ratingBridgeStrengthFromSet(set ratingSignalSet) float64 { + strength := 0.0 + + if set.HasPosition && set.Position > strength { + strength = set.Position + } + if set.HasBody && set.Body > strength { + strength = set.Body + } + if set.HasObject && set.Object > strength { + strength = set.Object + } + if set.HasClothing && set.Clothing > strength { + strength = set.Clothing + } + + return strength +} + +func ratingLabelsBridgeCompatible(a map[string]bool, b map[string]bool) bool { + for la := range a { + na := normalizeRatingSignalLabel(la) + if na == "" { + continue + } + + for lb := range b { + nb := normalizeRatingSignalLabel(lb) + if nb == "" { + continue + } + + if na == nb { + return true + } + } + } + + as := ratingSignalSetFromLabels(a) + bs := ratingSignalSetFromLabels(b) + + if as.HasPosition && bs.HasPosition { + return true + } + if as.HasBody && bs.HasBody { + return true + } + if as.HasObject && bs.HasObject { + return true + } + if as.HasClothing && bs.HasClothing { + return true + } + + return false +} + +func mergeRatingActivitySegments( + segments []aiSegmentMeta, + maxSilentGapSec float64, + minDurationSec float64, +) []aiSegmentMeta { if len(segments) == 0 { return nil } - // Pro normalisiertem Label separat mergen. - // Dadurch verhindern andere Labels zwischen zwei Treffern nicht mehr das Zusammenführen. - byLabel := make(map[string][]aiSegmentMeta) + valid := make([]aiSegmentMeta, 0, len(segments)) for _, s := range segments { if strings.TrimSpace(s.Label) == "" { continue } - if s.DurationSeconds <= 0 { - s.DurationSeconds = s.EndSeconds - s.StartSeconds - } + start := s.StartSeconds + end := s.EndSeconds - if s.EndSeconds <= s.StartSeconds { + if end <= start && s.DurationSeconds > 0 { + end = start + s.DurationSeconds + } + if end <= start { continue } - key := normalizeSegmentLabel(s.Label) - if key == "" { + labels := map[string]bool{} + addRatingLabelsFromSegment(labels, s.Label) + + if buildRatingComboLabel(labels) == "" { continue } - byLabel[key] = append(byLabel[key], s) + s.StartSeconds = start + s.EndSeconds = end + s.DurationSeconds = end - start + + if ratingSegmentSeverity(s.Label) <= 0 { + continue + } + + valid = append(valid, s) } - out := make([]aiSegmentMeta, 0, len(segments)) - - for _, items := range byLabel { - sort.SliceStable(items, func(i, j int) bool { - if items[i].StartSeconds != items[j].StartSeconds { - return items[i].StartSeconds < items[j].StartSeconds - } - if items[i].EndSeconds != items[j].EndSeconds { - return items[i].EndSeconds < items[j].EndSeconds - } - return items[i].Label < items[j].Label - }) - - cur := items[0] - - for i := 1; i < len(items); i++ { - n := items[i] - - gap := n.StartSeconds - cur.EndSeconds - - if gap >= -0.25 && gap <= maxGapSec { - oldDur := cur.DurationSeconds - if oldDur <= 0 { - oldDur = cur.EndSeconds - cur.StartSeconds - } - - newDur := n.DurationSeconds - if newDur <= 0 { - newDur = n.EndSeconds - n.StartSeconds - } - - totalDur := oldDur + newDur - - cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) - - if n.StartSeconds < cur.StartSeconds { - cur.StartSeconds = n.StartSeconds - } - if n.EndSeconds > cur.EndSeconds { - cur.EndSeconds = n.EndSeconds - } - - cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds - cur.AutoSelected = cur.AutoSelected || n.AutoSelected - - if totalDur > 0 { - cur.Score = ((cur.Score * oldDur) + (n.Score * newDur)) / totalDur - } else if n.Score > cur.Score { - cur.Score = n.Score - } - - continue - } - - out = append(out, cur) - cur = n - } - - out = append(out, cur) + if len(valid) == 0 { + return nil } - sort.SliceStable(out, func(i, j int) bool { - if out[i].StartSeconds != out[j].StartSeconds { - return out[i].StartSeconds < out[j].StartSeconds + sort.SliceStable(valid, func(i, j int) bool { + if valid[i].StartSeconds != valid[j].StartSeconds { + return valid[i].StartSeconds < valid[j].StartSeconds } - if out[i].EndSeconds != out[j].EndSeconds { - return out[i].EndSeconds < out[j].EndSeconds + if valid[i].EndSeconds != valid[j].EndSeconds { + return valid[i].EndSeconds < valid[j].EndSeconds } - return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) + return valid[i].Label < valid[j].Label }) + type activityBlock struct { + start float64 + end float64 + scoreSum float64 + scoreWeight float64 + labels map[string]bool + } + + newBlock := func(s aiSegmentMeta) activityBlock { + labels := map[string]bool{} + addRatingLabelsFromSegment(labels, s.Label) + + dur := s.EndSeconds - s.StartSeconds + conf := ratingClamp01(s.Score) + if conf <= 0 { + conf = 0.50 + } + + return activityBlock{ + start: s.StartSeconds, + end: s.EndSeconds, + scoreSum: conf * dur, + scoreWeight: dur, + labels: labels, + } + } + + finishBlock := func(b activityBlock) (aiSegmentMeta, bool) { + dur := b.end - b.start + if dur <= 0 { + return aiSegmentMeta{}, false + } + + label := buildRatingComboLabel(b.labels) + if label == "" { + return aiSegmentMeta{}, false + } + + sev := ratingSegmentSeverity(label) + + // Kurze Segmente dürfen bleiben, wenn sie stark genug sind. + if dur < minDurationSec && sev < 0.72 { + return aiSegmentMeta{}, false + } + + score := 0.0 + if b.scoreWeight > 0 { + score = b.scoreSum / b.scoreWeight + } + if score <= 0 { + score = 0.50 + } + + return aiSegmentMeta{ + Label: label, + Score: ratingClamp01(score), + StartSeconds: b.start, + EndSeconds: b.end, + DurationSeconds: dur, + AutoSelected: true, + Position: segmentPositionFromAnalyzeLabel(label), + Tags: segmentTagsFromAnalyzeLabel(label), + }, true + } + + out := make([]aiSegmentMeta, 0, len(valid)) + cur := newBlock(valid[0]) + + for i := 1; i < len(valid); i++ { + n := valid[i] + + gap := n.StartSeconds - cur.end + + shouldBridge := gap <= maxSilentGapSec + + if !shouldBridge && gap <= ratingBridgeStrongGapSec { + nextLabels := map[string]bool{} + addRatingLabelsFromSegment(nextLabels, n.Label) + + curSet := ratingSignalSetFromLabels(cur.labels) + nextSet := ratingSignalSetFromLabels(nextLabels) + + curBridgeStrength := ratingBridgeStrengthFromSet(curSet) + nextBridgeStrength := ratingBridgeStrengthFromSet(nextSet) + + curDur := cur.end - cur.start + nextDur := n.EndSeconds - n.StartSeconds + + shouldBridge = + ratingLabelsBridgeCompatible(cur.labels, nextLabels) && + curBridgeStrength >= 0.65 && + nextBridgeStrength >= 0.65 && + (curDur >= 8 || nextDur >= 8) + } + + if !shouldBridge { + if finished, ok := finishBlock(cur); ok { + out = append(out, finished) + } + + cur = newBlock(n) + continue + } + + if n.StartSeconds < cur.start { + cur.start = n.StartSeconds + } + if n.EndSeconds > cur.end { + cur.end = n.EndSeconds + } + + dur := n.EndSeconds - n.StartSeconds + if dur > 0 { + conf := ratingClamp01(n.Score) + if conf <= 0 { + conf = 0.50 + } + cur.scoreSum += conf * dur + cur.scoreWeight += dur + } + + addRatingLabelsFromSegment(cur.labels, n.Label) + } + + if finished, ok := finishBlock(cur); ok { + out = append(out, finished) + } + return out } +func prepareAIRatingSegments(segments []aiSegmentMeta) []aiSegmentMeta { + return mergeRatingActivitySegments( + segments, + ratingMaxSilentGapSec, + ratingMinSegmentDurationSec, + ) +} + func ratingConfidenceWeight(conf float64) float64 { conf = ratingClamp01(conf) - // Unterhalb ~0.30 soll Confidence kaum boosten. - // Oberhalb ~0.95 ist praktisch gesättigt. - n := ratingSmoothStep((conf - 0.30) / 0.65) + // Confidence soll relevant sein, aber schlechte Scores nicht komplett töten. + n := ratingSmoothStep((conf - 0.25) / 0.70) - return 0.60 + 0.40*n + return 0.58 + 0.42*n } -func starsFromNSFWScore(score float64) int { +func starsFromHighlightScore(score float64) int { switch { case score < 18: return 1 @@ -736,9 +1002,7 @@ func starsFromNSFWScore(score float64) int { } } -func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { - segments = mergeAdjacentAISegments(segments, 5.0) - +func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { r := &aiRatingMeta{ Score: 0, Stars: 1, @@ -752,7 +1016,6 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM var totalFlagged float64 var totalWeighted float64 - var totalEffectiveWeighted float64 var positionEffectiveWeighted float64 var contextEffectiveWeighted float64 @@ -763,10 +1026,6 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM var n int for _, s := range segments { - if isPersonSegmentLabel(s.Label) { - continue - } - segDur := s.DurationSeconds if segDur <= 0 { segDur = s.EndSeconds - s.StartSeconds @@ -775,12 +1034,17 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM continue } - sev := contextualSegmentSeverityWeight(s.Label) + sev := ratingSegmentSeverity(s.Label) if sev <= 0 { + appLogf("🧪 [rating] skip zero severity label=%q normalized=%q", s.Label, normalizeRatingSignalLabel(s.Label)) continue } conf := ratingClamp01(s.Score) + if conf <= 0 { + conf = 0.50 + } + confWeight := ratingConfidenceWeight(conf) quality := sev * confWeight @@ -794,7 +1058,6 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM totalFlagged += segDur totalWeighted += weightedDur - totalEffectiveWeighted += effectiveWeightedDur set := ratingSignalSetFromLabel(s.Label) if set.HasPosition { @@ -816,6 +1079,7 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM } if n == 0 { + appLogln("⚠️ [rating] result is zero because all segments were skipped") return r } @@ -824,43 +1088,40 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM segmentsPerMinute := float64(n) / videoMinutes avgConfidence := confSum / float64(n) - positionEffectiveWeightedPerMinute := positionEffectiveWeighted / videoMinutes - contextEffectiveWeightedPerMinute := contextEffectiveWeighted / videoMinutes + positionDensity := positionEffectiveWeighted / videoMinutes + contextDensity := contextEffectiveWeighted / videoMinutes - // Position ist der Haupttreiber. - // Kontext ohne Position zählt mit, wird aber schwächer normalisiert. peakNorm := ratingSmoothStep(peakQuality) - positionDensityNorm := ratingSoftCap(positionEffectiveWeightedPerMinute, 6.0) - contextDensityNorm := ratingSoftCap(contextEffectiveWeightedPerMinute, 10.0) - coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22) - frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.40) - longestNorm := ratingSoftCap(longest, 28.0) - confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60) + positionDensityNorm := ratingSoftCap(positionDensity, 5.5) + contextDensityNorm := ratingSoftCap(contextDensity, 8.5) + coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.20) + frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25) + longestNorm := ratingSoftCap(longest, 24.0) + confNorm := ratingSmoothStep((avgConfidence - 0.30) / 0.65) raw := - 0.34*peakNorm + - 0.28*positionDensityNorm + - 0.14*coverageNorm + - 0.10*contextDensityNorm + + 0.32*peakNorm + + 0.25*positionDensityNorm + + 0.17*contextDensityNorm + + 0.12*coverageNorm + 0.06*longestNorm + 0.04*frequencyNorm + 0.04*confNorm - // Sicherheits-Caps: - // Ohne Positionssignal soll Kontext alleine nicht auf 5 Sterne kippen. + // Ohne Position darf es trotzdem gut werden, aber nicht automatisch 5 Sterne. if positionEffectiveWeighted <= 0 { - raw = math.Min(raw, 0.72) + raw = math.Min(raw, 0.76) } - // Sehr kurze/spärliche Treffer nicht überbewerten. - if totalFlagged < 4.0 && n <= 1 { - raw = math.Min(raw, 0.42) + // Sehr wenig Material nicht überbewerten. + if totalFlagged < 5.0 && n <= 1 { + raw = math.Min(raw, 0.44) } score := ratingRound(ratingClamp01(raw)*100, 1) r.Score = score - r.Stars = starsFromNSFWScore(score) + r.Stars = starsFromHighlightScore(score) r.Segments = n r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) r.FlaggedSeconds = ratingRound(totalFlagged, 2) @@ -870,5 +1131,18 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM r.LongestSegmentSeconds = ratingRound(longest, 2) r.AvgConfidence = ratingRound(avgConfidence, 3) + appLogf( + "✅ [rating] result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f", + r.Score, + r.Stars, + r.Segments, + r.FlaggedSeconds, + r.WeightedFlaggedSeconds, + r.CoverageRatio, + r.WeightedCoverageRatio, + r.LongestSegmentSeconds, + r.AvgConfidence, + ) + return r } diff --git a/backend/record.go b/backend/record.go index 78033ff..df24977 100644 --- a/backend/record.go +++ b/backend/record.go @@ -20,11 +20,13 @@ import ( "time" ) +var trashCleanupMu sync.Mutex + // ---------------- Types ---------------- type prepareSplitReq struct { Output string `json:"output"` - Goal string `json:"goal,omitempty"` // z.B. "nsfw" + Goal string `json:"goal,omitempty"` // z.B. "highlights" } type prepareSplitResp struct { @@ -90,9 +92,19 @@ type durationItem struct { } type undoDeleteToken struct { - Trash string `json:"trash"` // basename in .trash (legacy/optional) - RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model" - File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" + Trash string `json:"trash"` // basename in .trash (legacy/optional) + RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model" + File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" + From string `json:"from,omitempty"` // "done" oder "keep" für Undo-Count/UI +} + +type trashMeta struct { + Token string `json:"token"` + TrashName string `json:"trashName"` + RelDir string `json:"relDir"` + File string `json:"file"` + From string `json:"from,omitempty"` + DeletedAt int64 `json:"deletedAt,omitempty"` } type bulkFilesRequest struct { @@ -777,8 +789,16 @@ func finishedPhaseTruthForID(id string) finishedPhaseTruth { 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 highlights, ok := analysis["highlights"].(map[string]any); ok && highlights != nil { + rawHits, hasHits := highlights["hits"].([]any) + rawSegs, hasSegs := highlights["segments"].([]any) + rawRating, hasRating := highlights["rating"].(map[string]any) + + if (hasHits && len(rawHits) > 0) || + (hasSegs && len(rawSegs) > 0) || + (hasRating && len(rawRating) > 0) { + out.AnalyzeReady = true + } } } } @@ -2371,7 +2391,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { c := *base - // vollständiges generated meta.json laden (inkl. analysis.ai.rating) + // vollständiges generated meta.json laden (inkl. analysis.highlights.rating) applyGeneratedMetaToRecordJobMeta(&c) // danach gezielte Wahrheiten/Fallbacks drüberlegen @@ -2501,6 +2521,56 @@ func releaseFileForMutation(file string) { } } +func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) { + trashDir = filepath.Clean(strings.TrimSpace(trashDir)) + keepToken = strings.TrimSpace(keepToken) + keepTrashName = strings.TrimSpace(keepTrashName) + + if trashDir == "" || keepToken == "" || keepTrashName == "" { + return + } + + keepMetaName := keepToken + ".json" + + entries, err := os.ReadDir(trashDir) + if err != nil { + appLogln("⚠️ trash cleanup read failed:", err) + return + } + + for _, e := range entries { + name := e.Name() + + // Das zuletzt gelöschte Video behalten + if name == keepTrashName { + continue + } + + // Token-Meta für Undo behalten + if name == keepMetaName { + continue + } + + // last.json behalten, damit Debug/Komfort weiterhin funktioniert + if name == "last.json" { + continue + } + + full := filepath.Join(trashDir, name) + + if e.IsDir() { + if err := os.RemoveAll(full); err != nil { + appLogln("⚠️ trash cleanup dir failed:", full, err) + } + continue + } + + if err := removeWithRetry(full); err != nil { + appLogln("⚠️ trash cleanup file failed:", full, err) + } + } +} + 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) @@ -2541,38 +2611,6 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { } trashDir := filepath.Join(doneAbs, ".trash") - - prevBase := "" - prevCanonical := "" - if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 { - var prev struct { - File string `json:"file"` - } - if err := json.Unmarshal(b, &prev); err == nil { - prevFile := strings.TrimSpace(prev.File) - if prevFile != "" { - prevBase = strings.TrimSuffix(prevFile, filepath.Ext(prevFile)) - prevCanonical = stripHotPrefix(prevBase) - } - } - } - - if err := os.RemoveAll(trashDir); err != nil { - if runtime.GOOS == "windows" && isSharingViolation(err) { - http.Error(w, "konnte .trash nicht leeren (Datei wird gerade verwendet). Bitte Player schließen und erneut versuchen.", http.StatusConflict) - return - } - http.Error(w, "trash leeren fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) - return - } - - if prevCanonical != "" { - removeGeneratedForID(prevCanonical) - if prevBase != "" && prevBase != prevCanonical { - removeGeneratedForID(prevBase) - } - } - if err := os.MkdirAll(trashDir, 0o755); err != nil { http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return @@ -2593,12 +2631,14 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { Trash: "", RelDir: relDir, File: file, + From: from, }) if err != nil { http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } + // Token ist RawURLEncoding-safe. Prefix macht Restore ohne last.json eindeutig. trashName := tok + "__" + file trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_") dst := filepath.Join(trashDir, trashName) @@ -2612,24 +2652,31 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { return } - type trashMeta struct { - Token string `json:"token"` - TrashName string `json:"trashName"` - RelDir string `json:"relDir"` - File string `json:"file"` - DeletedAt int64 `json:"deletedAt"` - } - meta := trashMeta{ Token: tok, TrashName: trashName, RelDir: relDir, File: file, + From: from, DeletedAt: time.Now().Unix(), } - b, _ := json.Marshal(meta) - _ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644) + if b, err := json.Marshal(meta); err == nil { + // Token-spezifische Meta für das aktuelle Undo. + _ = os.WriteFile(filepath.Join(trashDir, tok+".json"), b, 0o644) + + // Komfort/Debug. + _ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644) + + // .trash sauber halten: + // Nur das zuletzt gelöschte Video + dessen Meta + last.json behalten. + func() { + trashCleanupMu.Lock() + defer trashCleanupMu.Unlock() + + cleanupTrashKeepOnlyLatest(trashDir, tok, trashName) + }() + } purgeDurationCacheForPath(target) removeJobsByOutputBasename(file) @@ -2656,6 +2703,12 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { return } + tok, err := decodeUndoDeleteToken(raw) + if err != nil { + http.Error(w, "token ungültig", http.StatusBadRequest) + return + } + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2668,38 +2721,69 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { } trashDir := filepath.Join(doneAbs, ".trash") - metaPath := filepath.Join(trashDir, "last.json") - b, err := os.ReadFile(metaPath) - if err != nil { + var meta trashMeta + + // 1) Neue Variante: token-spezifische Meta. + metaPath := filepath.Join(trashDir, raw+".json") + if b, err := os.ReadFile(metaPath); err == nil && len(b) > 0 { + if err := json.Unmarshal(b, &meta); err != nil { + http.Error(w, "trash meta ungültig", http.StatusInternalServerError) + return + } + } else { + // 2) Fallback für alte/halb geschriebene Einträge: + // Datei anhand Token-Prefix suchen. + entries, rerr := os.ReadDir(trashDir) + if rerr != nil { + http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound) + return + } + + prefix := raw + "__" + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + + meta = trashMeta{ + Token: raw, + TrashName: name, + RelDir: tok.RelDir, + File: tok.File, + From: tok.From, + } + break + } + } + + if strings.TrimSpace(meta.Token) == "" || + strings.TrimSpace(meta.TrashName) == "" || + strings.TrimSpace(meta.File) == "" { http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound) return } - var meta struct { - Token string `json:"token"` - TrashName string `json:"trashName"` - RelDir string `json:"relDir"` - File string `json:"file"` - DeletedAt int64 `json:"deletedAt"` + if strings.TrimSpace(meta.From) == "" { + relLower := strings.ToLower(filepath.ToSlash(strings.TrimSpace(meta.RelDir))) + if relLower == "keep" || strings.HasPrefix(relLower, "keep/") { + meta.From = "keep" + } else { + meta.From = "done" + } } - if err := json.Unmarshal(b, &meta); err != nil { - http.Error(w, "trash meta ungültig", http.StatusInternalServerError) - return - } - if strings.TrimSpace(meta.Token) == "" || strings.TrimSpace(meta.TrashName) == "" || strings.TrimSpace(meta.File) == "" { - http.Error(w, "trash meta unvollständig", http.StatusInternalServerError) + + if meta.Token != raw { + http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound) return } - if raw != meta.Token { - http.Error(w, "token ungültig (nicht der letzte)", http.StatusNotFound) - return - } - - tok, err := decodeUndoDeleteToken(raw) - if err != nil { - http.Error(w, "token ungültig", http.StatusBadRequest) + if tok.File != meta.File || tok.RelDir != meta.RelDir { + http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound) return } @@ -2707,10 +2791,6 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { http.Error(w, "token inhalt ungültig", http.StatusBadRequest) return } - if tok.File != meta.File || tok.RelDir != meta.RelDir { - http.Error(w, "token passt nicht zu letzter Löschung", http.StatusNotFound) - return - } if !isAllowedVideoExt(meta.File) { http.Error(w, "nicht erlaubt", http.StatusForbidden) @@ -2723,6 +2803,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { if rel == "." { rel = "" } + dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel)) dstDirClean := filepath.Clean(dstDir) doneClean := filepath.Clean(doneAbs) @@ -2756,8 +2837,15 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { now := time.Now() _ = os.Chtimes(dst, now, now) - _ = os.RemoveAll(trashDir) - _ = os.MkdirAll(trashDir, 0o755) + _ = os.Remove(metaPath) + + // last.json nur löschen, wenn es zu genau diesem Token gehört. + if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 { + var last trashMeta + if json.Unmarshal(b, &last) == nil && last.Token == raw { + _ = os.Remove(filepath.Join(trashDir, "last.json")) + } + } purgeDurationCacheForPath(src) purgeDurationCacheForPath(dst) @@ -2768,6 +2856,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { "ok": true, "file": meta.File, "restoredFile": filepath.Base(dst), + "from": meta.From, }) } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 1d5809a..3730719 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -72,15 +72,92 @@ func RecordStream( return stream, nil } - stream, err := loadFreshHLS() - if err != nil { - return err + isRetryableFreshHLSError := func(err error) bool { + if err == nil { + return false + } + + msg := strings.ToLower(strings.TrimSpace(err.Error())) + + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "deadline exceeded") || + strings.Contains(msg, "awaiting headers") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "eof") || + strings.Contains(msg, "tls handshake timeout") || + strings.Contains(msg, "temporary failure") || + strings.Contains(msg, "stream-parsing") || + strings.Contains(msg, "room dossier nicht gefunden") || + strings.Contains(msg, "kein hls-quell-url") || + strings.Contains(msg, "leere hls url") || + strings.Contains(msg, "variant-playlist") || + strings.Contains(msg, "http 401") || + strings.Contains(msg, "http 403") || + strings.Contains(msg, "http 429") || + strings.Contains(msg, "http 5") } - if job != nil { - assetID := assetIDForJob(job) - if assetID != "" { - _, _ = ensureGeneratedDir(assetID) + loadFreshHLSWithRetry := func(maxAttempts int, reason string) (*selectedHLSStream, error) { + if maxAttempts < 1 { + maxAttempts = 1 + } + + var lastErr error + + for attempt := 1; attempt <= maxAttempts; attempt++ { + stream, err := loadFreshHLS() + if err == nil { + return stream, nil + } + + lastErr = err + + if ctx.Err() != nil { + return nil, ctx.Err() + } + + if !isRetryableFreshHLSError(err) { + return nil, err + } + + if attempt >= maxAttempts { + break + } + + wait := time.Duration(attempt) * 1500 * time.Millisecond + if wait > 8*time.Second { + wait = 8 * time.Second + } + + appLogln( + "⚠️ chaturbate hls refresh retry", + reason, + "attempt", + attempt, + "of", + maxAttempts, + "error:", + err, + ) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + } + + if lastErr == nil { + lastErr = errors.New("hls refresh failed") + } + + return nil, lastErr + } + + updatePreviewFromStream := func(stream *selectedHLSStream) { + if job == nil || stream == nil { + return } jobsMu.Lock() @@ -94,6 +171,23 @@ func RecordStream( jobsMu.Unlock() } + stream, err := loadFreshHLSWithRetry(5, "initial") + if err != nil { + return appErrorf( + "chaturbate seite/playlist konnte nach mehreren versuchen nicht geladen werden: %w", + err, + ) + } + + if job != nil { + assetID := assetIDForJob(job) + if assetID != "" { + _, _ = ensureGeneratedDir(assetID) + } + } + + updatePreviewFromStream(stream) + const maxPlaylistRefreshes = 12 refreshes := 0 attempt := 0 @@ -134,11 +228,17 @@ func RecordStream( shouldRefresh := strings.Contains(msg, "403") || strings.Contains(msg, "401") || + strings.Contains(msg, "429") || strings.Contains(msg, "invalid data found") || strings.Contains(msg, "error opening input") || strings.Contains(msg, "stream-parsing") || strings.Contains(msg, "kein hls-quell-url") || - strings.Contains(msg, "http ") + strings.Contains(msg, "http ") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "deadline exceeded") || + strings.Contains(msg, "awaiting headers") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "eof") if !shouldRefresh { return err @@ -155,8 +255,10 @@ func RecordStream( case <-time.After(1500 * time.Millisecond): } - newStream, rerr := loadFreshHLS() + newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error") if rerr != nil { + appLogln("⚠️ chaturbate hls refresh failed:", rerr) + select { case <-ctx.Done(): return ctx.Err() @@ -166,18 +268,7 @@ func RecordStream( } stream = newStream - - if job != nil { - jobsMu.Lock() - previewURL := strings.TrimSpace(stream.VideoURL) - if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { - previewURL = strings.TrimSpace(stream.MasterURL) - } - job.PreviewM3U8 = previewURL - job.PreviewCookie = httpCookie - job.PreviewUA = hc.userAgent - jobsMu.Unlock() - } + updatePreviewFromStream(stream) } } diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index 61fe471..dd2acdf 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -405,6 +405,14 @@ func appendFileAndRemove(dstPath, srcPath string) error { return nil } +func isExitStatus(err error, code int) bool { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() == code + } + return false +} + func handleM3U8Mode( ctx context.Context, stream *selectedHLSStream, @@ -452,7 +460,7 @@ func handleM3U8Mode( "-y", "-hide_banner", "-nostats", - "-loglevel", "warning", + "-loglevel", "error", "-protocol_whitelist", "file,http,https,tcp,tls,crypto", "-http_persistent", "false", } @@ -560,11 +568,13 @@ func handleM3U8Mode( close(stopStat) if err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg != "" { - return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg) + // Wenn der User den Recording-Prozess stoppt, beendet ffmpeg HLS unter Windows + // oft mit "exit status 1". Das ist in diesem Fall kein echter Fehler. + if ctx.Err() != nil && isExitStatus(err, 1) { + return nil } - return appErrorf("ffmpeg m3u8 failed: %w", err) + + return hlsFFmpegFailedError(job, referer, err, stderr.String()) } if job != nil { @@ -578,3 +588,80 @@ func handleM3U8Mode( return nil } + +func hlsErrorModelName(job *RecordJob, referer string) string { + if job != nil { + // 1) Beste Quelle bei dir: Output-Dateiname + // Beispiel: aurora_natsuki_05_08_2026__12-34-56.mp4 -> aurora_natsuki + if out := strings.TrimSpace(job.Output); out != "" { + stem := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out)) + stem = stripHotPrefix(stem) + + if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" { + return s + } + + if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" { + return s + } + } + + // 2) Fallback: SourceURL + if s := modelNameFromURL(strings.TrimSpace(job.SourceURL)); s != "" { + return s + } + + // 3) Fallback: Job-ID + if s := strings.TrimSpace(job.ID); s != "" { + return s + } + } + + // 4) Fallback: Referer + if s := modelNameFromURL(strings.TrimSpace(referer)); s != "" { + return s + } + + return "" +} + +func modelNameFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + u, err := url.Parse(raw) + if err != nil { + return "" + } + + if frag := strings.Trim(strings.TrimSpace(u.Fragment), "/"); frag != "" { + parts := strings.Split(frag, "/") + return strings.TrimSpace(parts[len(parts)-1]) + } + + path := strings.Trim(u.Path, "/") + if path == "" { + return "" + } + + parts := strings.Split(path, "/") + return strings.TrimSpace(parts[len(parts)-1]) +} + +func hlsFFmpegFailedError(job *RecordJob, referer string, err error, stderr string) error { + model := hlsErrorModelName(job, referer) + msg := strings.TrimSpace(stderr) + + prefix := "ffmpeg m3u8 failed" + if model != "" { + prefix = "[" + model + "] " + prefix + } + + if msg != "" { + return appErrorf("%s: %w: %s", prefix, err, msg) + } + + return appErrorf("%s: %w", prefix, err) +} diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 79319ce..2ab5163 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -30,34 +30,21 @@ func RecordStreamMFC( ) error { mfc := NewMyFreeCams(username) - // ✅ Statt sofort zu failen: kurz auf PUBLIC warten - const waitPublicMax = 2 * time.Minute + const waitPublicMax = 20 * time.Second deadline := time.Now().Add(waitPublicMax) - var lastSt *Status - for { - // Context cancel / stop if err := ctx.Err(); err != nil { return err } st, err := mfc.GetStatus() - if err == nil { - tmp := st - lastSt = &tmp - - if st == StatusPublic { - break - } + if err == nil && st == StatusPublic { + break } if time.Now().After(deadline) { - if lastSt == nil { - return appErrorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax) - } - return appErrorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt) - + return context.DeadlineExceeded } time.Sleep(5 * time.Second) @@ -81,17 +68,15 @@ func RecordStreamMFC( jobsMu.Lock() job.PreviewM3U8 = previewURL - job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen + job.PreviewCookie = "" job.PreviewUA = hc.userAgent jobsMu.Unlock() } - // ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar) if job != nil { _ = publishJob(job.ID) } - // Aufnahme starten return handleM3U8Mode(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) } diff --git a/backend/recorder-cert.pem b/backend/recorder-cert.pem new file mode 100644 index 0000000..33c3442 --- /dev/null +++ b/backend/recorder-cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEbjCCAtagAwIBAgIRAKGvq9b44yCGC32q8PwkueowDQYJKoZIhvcNAQELBQAw +gYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwnVEVH +RFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMTcwNQYDVQQDDC5t +a2NlcnQgVEVHRFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMB4X +DTI2MDUwODEwMDQzN1oXDTI4MDgwODEwMDQzN1owWzEnMCUGA1UEChMebWtjZXJ0 +IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdURUdEU1NEXFJvdGhl +ckBMMTRQQkJLOTUxMDAwMDYgKFJvdGhlcikwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDDghqU3igC3sNP2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4K +mJ6enU5zrGWI4+T0IHfbo737rvgk6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGs +zIQGJeI4TIeQ7sa7krCpMB/e1YxR1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3N +CC2OgxVYSh/hR9XNIIEPMoioQIYhle41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot ++aBlRjbxgOarLyST3XdtjAfC6CqxnzprX1LfznuULjatoS+pFDBDoy2B13xwa9Br +f+Xy3CswmSO+AzNomWfbeBkJnl6xb3Tb1urrAgMBAAGjfDB6MA4GA1UdDwEB/wQE +AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBR0Zph7hFKq4GpE +XO0y3U+T0iFoQDAyBgNVHREEKzApgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAA +AAAAAAAAAAGHBAoAARkwDQYJKoZIhvcNAQELBQADggGBAJShhgQS+9cwuVOJUIW+ +uy9t1ZAjR9qTd31kHdK1vu294GiavrG8RD+92BRujG4Fq1ambvqcu9sUaC8+0HAP +cQvdmnbTxG0ZHkGP7VVj3Poee0jpsVAVtV3aVwKxyO/E7Mv/nEKTqNfnECbEuiqC +tOJ8mI3E9tf2/ljzq2PqvfIPFBBtUT2lihNAFXM/06Nzoa1NPdfYd58N+VBFSOSq +zEM4fmpcqMThZGKGBoWmHwTE5Mw6mZFLyxHJpOFu5wNzyPJGes5o/F0ot5n+ZGah +GbYaGmhEedOxC3/WXRtexJmZ0jrB8Hkx+c/4h6+0HKU/PelrFJS+c2d6h8YGXyhv +pUsfyyoQzx7rSgyLfJqXK+52ZtnWNc0yVVMyKJwjvhv4mcP9l/q3UGR/i7xkD5Pc +2bLgM+wc8nKI7Yd/xejLpQDC0b/P1XibT1T3GRjiC/eu64G/uFOF+m3P5XvmF8JO +0cj8fXwUCq+TVD+22rFmtT+2VYkf8RUsC48Ou+PHccmDyw== +-----END CERTIFICATE----- diff --git a/backend/recorder-key.pem b/backend/recorder-key.pem new file mode 100644 index 0000000..67692b1 --- /dev/null +++ b/backend/recorder-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDDghqU3igC3sNP +2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4KmJ6enU5zrGWI4+T0IHfbo737rvgk +6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGszIQGJeI4TIeQ7sa7krCpMB/e1YxR +1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3NCC2OgxVYSh/hR9XNIIEPMoioQIYh +le41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot+aBlRjbxgOarLyST3XdtjAfC6Cqx +nzprX1LfznuULjatoS+pFDBDoy2B13xwa9Brf+Xy3CswmSO+AzNomWfbeBkJnl6x +b3Tb1urrAgMBAAECggEBAI31EBv72w2KdkKroot+vROCz6quMAdNvgezQif7VcHY +fuBN7EcBXltJWUeAbBEjeIESejUPBcmT2DXlF841CC5lB4VCaeV5lsreE9IsNYBf +CakIwLmZnltgcovydZT063QTJRcUDHAems5pjw76zMLKO+h9GEiMoUxFb3/atv3e +K5HE+uUd6JcPU0q91S6TxqvS9gH30OOkwH6Kl4tzWauQAKg/sVzYafbyTAuEpiMO +jLLLAn/sOmiL4knnRVu1FK4aZSH+t9BN9YuwfucXFn9jFgPtIJ0bF1GuqnwIeDI5 +BTNanHfaZRHD0+FbS0KbhARIFgDveoGrdoEtou0eDOECgYEA5dJqUvk96f7zCrxD +eSuUl1MvOjydY3GacfjevZoTxJDqh4Wt2wgLYJc4XB7tI02ci7NncecG8qPfqWq7 +aFjQAGAYdR+XvE7R2m3OFK9WbRX4cH6b+l7mSq0eOvcAAajn4RuNr/il+7Opw7zX +eKUB/pnW36B+ethdr5l+kDUQb8MCgYEA2ccanIT2QAxJb5j0F287NGhaTPuWfxcX +2T53I2fIwMiell0YXjwsKwKpckubRB2f5X4b9QJifUTmtxHky/Tx4H9OVhCaxumw +edol5YNzZaP/Tx8m2mqpyN2WPbezIFeA+GNcmDAxEo/NT2B64OVCHWKosndvb+Yk +GFmVb+g9zbkCgYEA1YFJLZRHJJ+pgour02HdRUgOU/gD72KWrNMbeuEtBCvs9cIG +5bjveOiDf3FrtKRhjpc4vuR12+zJ2EZDnIkFk5OypPyYpmRDKL1h+m15yRXkG/5D +QbHwF+gEcZsN8nzMDqDeXGCPMuqSCDnjoz0IQVMB//bGCbIANyZOIgJqJqkCgYEA +vHc+ZG383fjEJLvtoco1JmmYnD6uQ1Ys4WjZmd5bMdtswxvV1tekMaSgF7WurQgm +NGkqsKJbsaVLNOtbYdac7He/x2OfTr02aH2Nhk54M2H1tPd0nFjqjlaVitvLPRX9 +GviCTYKHNVUVjLgmHzLIQL382FXcLq6wVhJQ7QPDWKECgYA6ZmdB8waN2SFOJRnj +5zWTgdImicl4yCu8PaEyloO9whYSdYH8zU3K+FKaowXGNInp3BGCmP9TUwoG8Iwx +Ug5d55P2FgwnhCi59Y6dMXih1p94BuQ0vcWPBqztofl7lZbBEnjwzKVycFvcyNFX +QFb/UA4/CfQli9ciDtlqzZt8SA== +-----END PRIVATE KEY----- diff --git a/backend/recorder.go b/backend/recorder.go index db10af5..676c682 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -425,6 +425,43 @@ func concurrentDownloadsLimitState() (enabled bool, max int, active int, reached return } +type downloadLimitReachedError struct { + Active int + Max int +} + +func (e downloadLimitReachedError) Error() string { + return fmt.Sprintf("Download-Limit erreicht (%d/%d aktiv)", e.Active, e.Max) +} + +func isDownloadLimitReachedError(err error) bool { + var e downloadLimitReachedError + return errors.As(err, &e) +} + +var lastDownloadLimitLogAt time.Time + +func logDownloadLimitReachedThrottled(err error) { + if err == nil { + return + } + + if !isDownloadLimitReachedError(err) { + appLogln("❌", err) + return + } + + now := time.Now() + + // Nur alle 60 Sekunden ins Log schreiben. + if !lastDownloadLimitLogAt.IsZero() && now.Sub(lastDownloadLimitLogAt) < 60*time.Second { + return + } + + lastDownloadLimitLogAt = now + appLogln("❌", err) +} + func startRecordingInternal(req RecordRequest) (*RecordJob, error) { url := strings.TrimSpace(req.URL) if url == "" { @@ -470,7 +507,10 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } if active >= max { - return nil, appErrorf("Download-Limit erreicht (%d/%d aktiv)", active, max) + return nil, downloadLimitReachedError{ + Active: active, + Max: max, + } } } @@ -1097,9 +1137,12 @@ func enqueuePostworkOrFail(job *RecordJob, out string, postTarget JobStatus) { jobsMu.Lock() job.Phase = "postwork" job.PostWorkKey = postKey - { - s := postWorkQ.StatusForKey(postKey) - job.PostWork = &s + job.PostWork = &PostWorkKeyStatus{ + State: "queued", + Position: 0, + Waiting: 0, + Running: 0, + MaxParallel: 1, } jobsMu.Unlock() diff --git a/backend/routes.go b/backend/routes.go index ba41375..276c39e 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -17,10 +17,21 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { mux.HandleFunc("/api/auth/logout", authLogoutHandler(auth)) mux.HandleFunc("/api/auth/me", authMeHandler(auth)) + mux.HandleFunc("/api/auth/password/change", authPasswordChangeHandler(auth)) + // 2FA (Authenticator/TOTP) + mux.HandleFunc("/api/auth/2fa/setup", auth2FASetupHandler(auth)) mux.HandleFunc("/api/auth/2fa/enable", auth2FAEnableHandler(auth)) - // mux.HandleFunc("/api/auth/2fa/disable", auth2FADisableHandler(auth)) + mux.HandleFunc("/api/auth/2fa/disable", auth2FADisableHandler(auth)) + mux.HandleFunc("/api/auth/2fa/cancel-setup", auth2FACancelSetupHandler(auth)) + + // Passkeys / WebAuthn + mux.HandleFunc("/api/auth/passkey/register/options", authPasskeyRegisterOptionsHandler(auth)) + mux.HandleFunc("/api/auth/passkey/register/verify", authPasskeyRegisterVerifyHandler(auth)) + mux.HandleFunc("/api/auth/passkey/login/options", authPasskeyLoginOptionsHandler(auth)) + mux.HandleFunc("/api/auth/passkey/login/verify", authPasskeyLoginVerifyHandler(auth)) + mux.HandleFunc("/api/auth/passkey/delete", authPasskeyDeleteHandler(auth)) // -------------------------- // 2) Protected API Mux @@ -77,11 +88,14 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/next", trainingNextHandler) api.HandleFunc("/api/training/frame", trainingFrameHandler) api.HandleFunc("/api/training/feedback", trainingFeedbackHandler) + api.HandleFunc("/api/training/feedback/list", trainingFeedbackListHandler) + api.HandleFunc("/api/training/feedback/update", trainingFeedbackUpdateHandler) api.HandleFunc("/api/training/train", trainingTrainHandler) api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) + api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/server.go b/backend/server.go index 4267c45..a402c0b 100644 --- a/backend/server.go +++ b/backend/server.go @@ -2,6 +2,7 @@ package main import ( + "bytes" "context" "fmt" "net" @@ -15,8 +16,23 @@ import ( "sync" "syscall" "time" + + "github.com/joho/godotenv" ) +var embeddedTLSOnce sync.Once +var embeddedTLSCertPath string +var embeddedTLSKeyPath string +var embeddedTLSErr error + +func embeddedTLSPathsCached() (string, string, error) { + embeddedTLSOnce.Do(func() { + embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr = embeddedTLSFilesDir() + }) + + return embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr +} + func escapePowerShell(s string) string { return strings.ReplaceAll(s, "'", "''") } @@ -174,13 +190,8 @@ func findAIServerScriptDir() (string, error) { ) } - appLogln("🔎 Suche ai_server.py") - appLogln(" cwd:", cwd) - appLogln(" exeDir:", exeDir) - for _, path := range candidates { cleanPath := filepath.Clean(path) - appLogln(" prüfe:", cleanPath) if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() { appLogln("✅ ai_server.py gefunden:", cleanPath) @@ -305,7 +316,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { "ai_server:app", "--host", "127.0.0.1", "--port", port, - "--log-level", "warning", + "--log-level", "error", ) cmd := exec.CommandContext(ctx, pythonPath, args...) @@ -337,15 +348,6 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { cmd.Env = env - appLogf( - "--- AI Server Start --- python=%s scriptDir=%s url=%s port=%s args=%s", - pythonPath, - scriptDir, - aiServerURL(), - port, - strings.Join(args, " "), - ) - logWriter := appLogWriter() cmd.Stdout = logWriter cmd.Stderr = logWriter @@ -371,7 +373,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { if err != nil && ctx.Err() == nil { appLogf("⚠️ AI Server beendet: %v", err) } else { - appLogln("AI Server beendet.") + appLogln("🛑 AI Server beendet.") } }() @@ -394,17 +396,150 @@ func (p *aiServerProcess) Stop() { } if p.cmd != nil && p.cmd.Process != nil { - appLogln("🛑 Beende AI Server...") _ = p.cmd.Process.Kill() } } +func setEnvIfMissing(key, value string) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + + if key == "" { + return + } + + if strings.TrimSpace(os.Getenv(key)) != "" { + return + } + + _ = os.Setenv(key, value) +} + +func loadEmbeddedDotEnv() { + b, err := embeddedDotEnvBytes() + if err != nil { + appLogln("ℹ️ Keine eingebettete .env gefunden.") + return + } + + envMap, err := godotenv.Parse(bytes.NewReader(b)) + if err != nil { + appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err) + return + } + + for k, v := range envMap { + setEnvIfMissing(k, v) + } + + appLogln("✅ Eingebettete .env geladen") +} + +func loadExternalDotEnvNextToExe() { + exePath, err := os.Executable() + if err != nil { + if err := godotenv.Overload(); err == nil { + appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis") + } + return + } + + exeDir := filepath.Dir(exePath) + envPath := filepath.Join(exeDir, ".env") + + if err := godotenv.Overload(envPath); err == nil { + appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath) + return + } + + if err := godotenv.Overload(); err == nil { + appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env") + } +} + +func loadAppEnv() { + loadEmbeddedDotEnv() + loadExternalDotEnvNextToExe() +} + +func httpsEnabled() bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED"))) + return raw == "1" || raw == "true" || raw == "yes" || raw == "on" +} + +func resolveMaybeRelativeToExe(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + if filepath.IsAbs(path) { + return path + } + + exePath, err := os.Executable() + if err != nil || exePath == "" { + return path + } + + return filepath.Join(filepath.Dir(exePath), path) +} + +func tlsCertFile() string { + raw := strings.TrimSpace(os.Getenv("TLS_CERT_FILE")) + if raw != "" { + return resolveMaybeRelativeToExe(raw) + } + + certPath, _, err := embeddedTLSPathsCached() + if err == nil && certPath != "" { + return certPath + } + + return resolveMaybeRelativeToExe("recorder-cert.pem") +} + +func tlsKeyFile() string { + raw := strings.TrimSpace(os.Getenv("TLS_KEY_FILE")) + if raw != "" { + return resolveMaybeRelativeToExe(raw) + } + + _, keyPath, err := embeddedTLSPathsCached() + if err == nil && keyPath != "" { + return keyPath + } + + return resolveMaybeRelativeToExe("recorder-key.pem") +} + +func publicAppURL() string { + scheme := "http" + if httpsEnabled() { + scheme = "https" + } + + host := strings.TrimSpace(os.Getenv("APP_HOST")) + if host == "" { + host = "localhost" + } + + port := strings.TrimSpace(os.Getenv("APP_PORT")) + if port == "" { + port = "9999" + } + + return scheme + "://" + host + ":" + port +} + // --- main --- func main() { clearAppLog() initAppLog() defer closeAppLog() + loadAppEnv() + loadSettings() appCtx, appCancel := context.WithCancel(context.Background()) @@ -489,7 +624,13 @@ func main() { appLogln("⚠️ covers dir:", err) } - appLogln("🌐 HTTP-API aktiv: http://localhost:9999") + if httpsEnabled() { + appLogln("🌐 HTTPS-API aktiv:", publicAppURL()) + appLogln("🔐 TLS Cert:", tlsCertFile()) + appLogln("🔐 TLS Key: ", tlsKeyFile()) + } else { + appLogln("🌐 HTTP-API aktiv: http://localhost:9999") + } handler := withCORS(mux) srv := &http.Server{ @@ -500,8 +641,6 @@ func main() { var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { - appLogln("🛑 Shutdown gestartet") - if aiProc != nil { aiProc.Stop() } @@ -512,6 +651,7 @@ func main() { defer cancel() _ = srv.Shutdown(ctx) + appLogln("🛑 Server beendet.") closeNSFW() }) @@ -528,10 +668,19 @@ func main() { serverErrCh := make(chan error, 1) go func() { - if err := srv.Serve(appLn); err != nil && err != http.ErrServerClosed { + var err error + + if httpsEnabled() { + err = srv.ServeTLS(appLn, tlsCertFile(), tlsKeyFile()) + } else { + err = srv.Serve(appLn) + } + + if err != nil && err != http.ErrServerClosed { serverErrCh <- err return } + serverErrCh <- nil }() diff --git a/backend/split.go b/backend/split.go index 323f278..a3ad486 100644 --- a/backend/split.go +++ b/backend/split.go @@ -584,7 +584,7 @@ func splitSingleSegment( "-nostats", "-progress", "pipe:1", "-hide_banner", - "-loglevel", "warning", + "-loglevel", "error", "-ss", formatFFSec(startSec), "-i", srcPath, "-t", formatFFSec(durSec), diff --git a/backend/sse.go b/backend/sse.go index 5549b46..deb5a1a 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -83,13 +83,16 @@ type taskStateEvent struct { } type analysisProgressEvent struct { - Type string `json:"type"` // "analysis_progress" + Type string `json:"type"` + Scope string `json:"scope,omitempty"` + RequestID string `json:"requestId,omitempty"` Running bool `json:"running"` Phase string `json:"phase,omitempty"` - Progress float64 `json:"progress"` // 0..1 + Progress float64 `json:"progress"` Current int `json:"current,omitempty"` Total int `json:"total,omitempty"` File string `json:"file,omitempty"` + SourceFile string `json:"sourceFile,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` StartedAtMs int64 `json:"startedAtMs,omitempty"` diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index c83adea..b0e2098 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -915,7 +915,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse") - _, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw") + _, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "highlights") if skipFile, stopAll := handleFileAbort(aerr); stopAll { return } else if skipFile { diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index 7e86c25..78f31f1 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -90,7 +90,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string) assetID = strings.TrimSpace(assetID) goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { - goal = "nsfw" + goal = "highlights" } if assetID == "" && videoPath != "" { @@ -132,7 +132,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string) } func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error { - truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw") + truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights") if !truth.MetaReady { return errors.New("meta.json fehlt oder ist noch unvollständig") @@ -147,7 +147,7 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e } func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error { - truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw") + truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights") if !truth.SpritesReady { return errors.New("preview-sprite.jpg fehlt oder ist leer") @@ -334,7 +334,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { id := job.AssetID ctx := job.Ctx requiredGoals := requiredAnalyzeGoals() - primaryGoal := "nsfw" + primaryGoal := "highlights" setRegenerateAssetsTaskRunning(file, file) cancelDeferredEnrichForFile(file) diff --git a/backend/training.go b/backend/training.go index bedb69a..246c60a 100644 --- a/backend/training.go +++ b/backend/training.go @@ -24,6 +24,8 @@ import ( "time" ) +const trainingUncertainCandidateCount = 5 + type TrainingLabels struct { People []string `json:"people"` SexPositions []string `json:"sexPositions"` @@ -69,14 +71,27 @@ type TrainingCorrection struct { } type TrainingSample struct { - SampleID string `json:"sampleId"` - FrameURL string `json:"frameUrl"` - SourceFile string `json:"sourceFile"` - SourcePath string `json:"sourcePath,omitempty"` - SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"` - Second float64 `json:"second"` - CreatedAt string `json:"createdAt"` - Prediction TrainingPrediction `json:"prediction"` + SampleID string `json:"sampleId"` + FrameURL string `json:"frameUrl"` + SourceFile string `json:"sourceFile"` + SourcePath string `json:"sourcePath,omitempty"` + SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"` + Second float64 `json:"second"` + CreatedAt string `json:"createdAt"` + UncertaintyScore float64 `json:"uncertaintyScore,omitempty"` + Prediction TrainingPrediction `json:"prediction"` +} + +type trainingUncertainQueueItem struct { + SampleID string `json:"sampleId"` + UncertaintyScore float64 `json:"uncertaintyScore"` + SourceFile string `json:"sourceFile,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +type trainingUncertainCandidate struct { + sample *TrainingSample + score float64 } type TrainingFeedbackRequest struct { @@ -86,6 +101,18 @@ type TrainingFeedbackRequest struct { Notes string `json:"notes,omitempty"` } +type TrainingFeedbackUpdateRequest struct { + SampleID string `json:"sampleId"` + AnsweredAt string `json:"answeredAt"` + Accepted bool `json:"accepted"` + Correction *TrainingCorrection `json:"correction,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type TrainingSkipRequest struct { + SampleID string `json:"sampleId"` +} + type TrainingAnnotation struct { SampleID string `json:"sampleId"` FrameURL string `json:"frameUrl"` @@ -163,6 +190,435 @@ type trainingProgressEvent struct { Epochs int `json:"epochs,omitempty"` } +type TrainingFeedbackListResponse struct { + OK bool `json:"ok"` + Items []TrainingAnnotation `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasMore bool `json:"hasMore"` +} + +func trainingFeedbackListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + limit := 30 + offset := 0 + + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil { + limit = n + } + } + + if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil { + offset = n + } + } + + if limit < 1 { + limit = 30 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + + items, err := trainingReadAnnotations(root) + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Neueste zuerst. + sort.SliceStable(items, func(i, j int) bool { + ai := strings.TrimSpace(items[i].AnsweredAt) + aj := strings.TrimSpace(items[j].AnsweredAt) + + if ai == aj { + return items[i].CreatedAt > items[j].CreatedAt + } + + return ai > aj + }) + + query := strings.TrimSpace(r.URL.Query().Get("q")) + filter := strings.TrimSpace(r.URL.Query().Get("filter")) + + items = trainingFilterAnnotations(items, query, filter) + + total := len(items) + + if offset > total { + offset = total + } + + end := offset + limit + if end > total { + end = total + } + + page := items[offset:end] + + trainingWriteJSON(w, http.StatusOK, TrainingFeedbackListResponse{ + OK: true, + Items: page, + Total: total, + Limit: limit, + Offset: offset, + HasMore: end < total, + }) +} + +func trainingReadAnnotations(root string) ([]TrainingAnnotation, error) { + path := filepath.Join(root, "feedback.jsonl") + + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []TrainingAnnotation{}, nil + } + + return nil, err + } + + items := []TrainingAnnotation{} + + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var item TrainingAnnotation + if err := json.Unmarshal([]byte(line), &item); err != nil { + continue + } + + // Alte Einträge robust machen. + if strings.TrimSpace(item.FrameURL) == "" && strings.TrimSpace(item.SampleID) != "" { + item.FrameURL = "/api/training/frame?id=" + item.SampleID + } + + items = append(items, item) + } + + return items, nil +} + +func trainingFilterAnnotations( + items []TrainingAnnotation, + query string, + filter string, +) []TrainingAnnotation { + cleanQuery := strings.ToLower(strings.TrimSpace(query)) + cleanFilter := strings.ToLower(strings.TrimSpace(filter)) + + out := make([]TrainingAnnotation, 0, len(items)) + + for _, item := range items { + switch cleanFilter { + case "accepted": + if !item.Accepted { + continue + } + + case "corrected": + if item.Accepted { + continue + } + } + + if cleanQuery != "" && !trainingAnnotationMatchesQuery(item, cleanQuery) { + continue + } + + out = append(out, item) + } + + return out +} + +func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string) bool { + effective := trainingEffectiveCorrection(item) + + parts := []string{ + item.SampleID, + item.SourceFile, + item.SourcePath, + item.CreatedAt, + item.AnsweredAt, + item.Notes, + effective.SexPosition, + } + + parts = append(parts, effective.PeoplePresent...) + parts = append(parts, effective.BodyPartsPresent...) + parts = append(parts, effective.ObjectsPresent...) + parts = append(parts, effective.ClothingPresent...) + + for _, box := range effective.Boxes { + parts = append(parts, box.Label) + } + + haystack := strings.ToLower(strings.Join(parts, " ")) + + return strings.Contains(haystack, cleanQuery) +} + +func trainingRemoveSampleFromUncertainQueue(root string, sampleID string) error { + sampleID = strings.TrimSpace(sampleID) + if sampleID == "" { + return nil + } + + items, err := trainingReadUncertainQueue(root) + if err != nil { + return err + } + + if len(items) == 0 { + return nil + } + + next := make([]trainingUncertainQueueItem, 0, len(items)) + changed := false + + for _, item := range items { + if strings.TrimSpace(item.SampleID) == sampleID { + changed = true + continue + } + + next = append(next, item) + } + + if !changed { + return nil + } + + return trainingWriteUncertainQueue(root, next) +} + +func trainingReadValidUncertainCandidates(root string) ([]trainingUncertainCandidate, error) { + items, err := trainingReadUncertainQueue(root) + if err != nil { + return nil, err + } + + if len(items) == 0 { + return []trainingUncertainCandidate{}, nil + } + + answered, err := trainingAnsweredSampleIDs(root) + if err != nil { + return nil, err + } + + candidates := make([]trainingUncertainCandidate, 0, len(items)) + + for _, item := range items { + id := strings.TrimSpace(item.SampleID) + if id == "" || answered[id] { + continue + } + + framePath := filepath.Join(root, "frames", id+".jpg") + if !fileExistsNonEmpty(framePath) { + continue + } + + sample, err := trainingReadSample(root, id) + if err != nil { + continue + } + + score := item.UncertaintyScore + if score <= 0 { + score = sample.UncertaintyScore + } + if score <= 0 { + score = trainingPredictionUncertaintyScore(sample.Prediction) + } + + score = clamp01(score) + sample.UncertaintyScore = score + + candidates = append(candidates, trainingUncertainCandidate{ + sample: sample, + score: score, + }) + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt + } + + return candidates[i].score > candidates[j].score + }) + + return candidates, nil +} + +func trainingWriteUncertainCandidateQueue(root string, candidates []trainingUncertainCandidate) error { + items := make([]trainingUncertainQueueItem, 0, len(candidates)) + + for _, candidate := range candidates { + if candidate.sample == nil { + continue + } + + id := strings.TrimSpace(candidate.sample.SampleID) + if id == "" { + continue + } + + items = append(items, trainingUncertainQueueItem{ + SampleID: id, + UncertaintyScore: clamp01(candidate.score), + SourceFile: candidate.sample.SourceFile, + CreatedAt: candidate.sample.CreatedAt, + }) + } + + return trainingWriteUncertainQueue(root, items) +} + +func trainingCreateUncertainCandidateWithProgress( + root string, + startedAtMs int64, + requestID string, + stepStart int, + stepTotal int, + prefix string, +) (*trainingUncertainCandidate, error) { + sample, err := trainingCreateNextSampleWithProgressRange( + startedAtMs, + requestID, + stepStart, + stepTotal, + prefix, + ) + if err != nil { + return nil, err + } + + score := trainingPredictionUncertaintyScore(sample.Prediction) + score = clamp01(score) + + sample.UncertaintyScore = score + + if err := trainingWriteSample(root, sample); err != nil { + return nil, err + } + + return &trainingUncertainCandidate{ + sample: sample, + score: score, + }, nil +} + +func trainingUncertainQueuePath(root string) string { + return filepath.Join(root, "uncertain_queue.json") +} + +func trainingReadUncertainQueue(root string) ([]trainingUncertainQueueItem, error) { + path := trainingUncertainQueuePath(root) + + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []trainingUncertainQueueItem{}, nil + } + + return nil, err + } + + var items []trainingUncertainQueueItem + if err := json.Unmarshal(b, &items); err != nil { + return []trainingUncertainQueueItem{}, nil + } + + return items, nil +} + +func trainingWriteUncertainQueue(root string, items []trainingUncertainQueueItem) error { + path := trainingUncertainQueuePath(root) + + if len(items) == 0 { + _ = os.Remove(path) + return nil + } + + b, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, b, 0644) +} + +func trainingPopQueuedUncertainSample(root string) (*TrainingSample, bool, error) { + items, err := trainingReadUncertainQueue(root) + if err != nil { + return nil, false, err + } + + if len(items) == 0 { + return nil, false, nil + } + + answered, err := trainingAnsweredSampleIDs(root) + if err != nil { + return nil, false, err + } + + remaining := make([]trainingUncertainQueueItem, 0, len(items)) + + for index, item := range items { + id := strings.TrimSpace(item.SampleID) + if id == "" || answered[id] { + continue + } + + framePath := filepath.Join(root, "frames", id+".jpg") + if !fileExistsNonEmpty(framePath) { + continue + } + + sample, err := trainingReadSample(root, id) + if err != nil { + continue + } + + sample.UncertaintyScore = item.UncertaintyScore + + remaining = append(remaining, items[index+1:]...) + + if err := trainingWriteUncertainQueue(root, remaining); err != nil { + return nil, false, err + } + + return sample, true, nil + } + + _ = trainingWriteUncertainQueue(root, []trainingUncertainQueueItem{}) + return nil, false, nil +} + func trainingScaleProgress(local float64, start int, end int) int { if math.IsNaN(local) || math.IsInf(local, 0) { local = 0 @@ -234,6 +690,145 @@ func trainingPublishJobStatus(status TrainingJobStatus) { publishSSE("training", b) } +func trainingPublishAnalysisStep( + requestID string, + startedAtMs int64, + current int, + total int, + sourceFile string, + message string, +) { + progress := 0.0 + if total > 0 { + progress = float64(current) / float64(total) + } + + b, err := json.Marshal(map[string]any{ + "type": "analysis_progress", + "scope": "training", + "requestId": requestID, + "running": true, + "phase": "running", + "progress": progress, + "startedAtMs": startedAtMs, + "current": current, + "total": total, + "sourceFile": strings.TrimSpace(sourceFile), + "message": strings.TrimSpace(message), + "ts": time.Now().UnixMilli(), + }) + if err != nil { + return + } + + publishSSE("analysisProgress", b) +} + +func trainingPublishAnalysisStarted( + requestID string, + total int, + sourceFile string, + message string, +) int64 { + startedAtMs := time.Now().UnixMilli() + + b, err := json.Marshal(map[string]any{ + "type": "analysis_progress", + "scope": "training", + "requestId": requestID, + "running": true, + "phase": "starting", + "progress": 0, + "startedAtMs": startedAtMs, + "current": 0, + "total": total, + "sourceFile": strings.TrimSpace(sourceFile), + "message": strings.TrimSpace(message), + "ts": time.Now().UnixMilli(), + }) + if err == nil { + publishSSE("analysisProgress", b) + } + + return startedAtMs +} + +func trainingPublishAnalysisFinished( + requestID string, + startedAtMs int64, + total int, + sourceFile string, + message string, +) { + finishedAtMs := time.Now().UnixMilli() + durationMs := finishedAtMs - startedAtMs + if durationMs < 0 { + durationMs = 0 + } + + b, err := json.Marshal(map[string]any{ + "type": "analysis_progress", + "scope": "training", + "requestId": requestID, + "running": false, + "phase": "done", + "progress": 1, + "startedAtMs": startedAtMs, + "finishedAtMs": finishedAtMs, + "durationMs": durationMs, + "current": total, + "total": total, + "sourceFile": strings.TrimSpace(sourceFile), + "message": strings.TrimSpace(message), + "ts": time.Now().UnixMilli(), + }) + if err != nil { + return + } + + publishSSE("analysisProgress", b) +} + +func trainingPublishAnalysisError( + requestID string, + startedAtMs int64, + sourceFile string, + message string, + err error, +) { + finishedAtMs := time.Now().UnixMilli() + durationMs := finishedAtMs - startedAtMs + if durationMs < 0 { + durationMs = 0 + } + + errText := "" + if err != nil { + errText = err.Error() + } + + b, marshalErr := json.Marshal(map[string]any{ + "type": "analysis_progress", + "scope": "training", + "requestId": requestID, + "running": false, + "phase": "error", + "progress": 0, + "startedAtMs": startedAtMs, + "finishedAtMs": finishedAtMs, + "durationMs": durationMs, + "sourceFile": strings.TrimSpace(sourceFile), + "message": strings.TrimSpace(message), + "error": errText, + "ts": time.Now().UnixMilli(), + }) + if marshalErr != nil { + return + } + + publishSSE("analysisProgress", b) +} + func trainingRunCommandStreaming( ctx context.Context, python string, @@ -443,6 +1038,8 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { forceNew := r.URL.Query().Get("force") == "1" || strings.EqualFold(r.URL.Query().Get("force"), "true") + analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId")) + preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") || strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain") @@ -459,19 +1056,39 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { var startedAtMs int64 if refreshPrediction { - startedAtMs = publishAnalysisStarted("", 2, "Aktuelles Bild wird neu analysiert…") + startedAtMs = time.Now().UnixMilli() + trainingPublishAnalysisStep( + analysisRequestID, + startedAtMs, + 1, + 2, + "", + "Aktuelles Bild wird neu analysiert…", + ) } - if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil { + if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID); err != nil { if refreshPrediction { - publishAnalysisError(startedAtMs, "", "Aktuelles Bild konnte nicht neu analysiert werden.", err) + trainingPublishAnalysisError( + analysisRequestID, + startedAtMs, + "", + "Aktuelles Bild konnte nicht neu analysiert werden.", + err, + ) } trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } else if ok { if refreshPrediction { - publishAnalysisFinished(startedAtMs, 2, sample.SourceFile, "Analyse abgeschlossen.") + trainingPublishAnalysisFinished( + analysisRequestID, + startedAtMs, + 2, + sample.SourceFile, + "Analyse abgeschlossen.", + ) } trainingWriteJSON(w, http.StatusOK, sample) @@ -479,34 +1096,57 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { } } - startedAtMs := publishAnalysisStarted("", 4, func() string { - if preferUncertain { - return "Unsichere Prediction wird gesucht…" - } + startedAtMs := trainingPublishAnalysisStarted( + analysisRequestID, + func() int { + if preferUncertain { + return trainingUncertainCandidateCount*4 + 1 + } - return "Neues Trainingsbild wird vorbereitet…" - }()) + return 4 + }(), + "", + func() string { + if preferUncertain { + return "Unsichere Prediction wird gesucht…" + } + + return "Neues Trainingsbild wird vorbereitet…" + }(), + ) var sample *TrainingSample if preferUncertain { - sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs) + sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs, analysisRequestID) } else { - sample, err = trainingCreateNextSampleWithProgress(startedAtMs) + sample, err = trainingCreateNextSampleWithProgress(startedAtMs, analysisRequestID) } if err != nil { - publishAnalysisError(startedAtMs, "", "Trainingsbild konnte nicht erstellt werden.", err) + trainingPublishAnalysisError( + analysisRequestID, + startedAtMs, + "", + "Trainingsbild konnte nicht erstellt werden.", + err, + ) trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } - publishAnalysisFinished(startedAtMs, 4, sample.SourceFile, "Analyse abgeschlossen.") + trainingPublishAnalysisFinished( + analysisRequestID, + startedAtMs, + 4, + sample.SourceFile, + "Analyse abgeschlossen.", + ) trainingWriteJSON(w, http.StatusOK, sample) } -func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64) (*TrainingSample, bool, error) { +func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64, requestID string) (*TrainingSample, bool, error) { answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, false, err @@ -578,7 +1218,8 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i sourceFile = filepath.Base(sample.SourcePath) } - publishAnalysisStep( + trainingPublishAnalysisStep( + requestID, startedAtMs, 1, 2, @@ -588,7 +1229,8 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i sample.Prediction = trainingPredictFrame(framePath) - publishAnalysisStep( + trainingPublishAnalysisStep( + requestID, startedAtMs, 2, 2, @@ -768,6 +1410,166 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { }) } +func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut && r.Method != http.MethodPost { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req TrainingFeedbackUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + trainingWriteError(w, http.StatusBadRequest, "invalid json") + return + } + + req.SampleID = strings.TrimSpace(req.SampleID) + req.AnsweredAt = strings.TrimSpace(req.AnsweredAt) + + if req.SampleID == "" { + trainingWriteError(w, http.StatusBadRequest, "sampleId missing") + return + } + + if req.AnsweredAt == "" { + trainingWriteError(w, http.StatusBadRequest, "answeredAt missing") + return + } + + if strings.Contains(req.SampleID, "/") || strings.Contains(req.SampleID, "\\") { + trainingWriteError(w, http.StatusBadRequest, "invalid sampleId") + return + } + + if !req.Accepted && req.Correction == nil { + trainingWriteError(w, http.StatusBadRequest, "correction missing") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + items, err := trainingReadAnnotations(root) + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + matchIndex := -1 + + for i, item := range items { + if strings.TrimSpace(item.SampleID) == req.SampleID && + strings.TrimSpace(item.AnsweredAt) == req.AnsweredAt { + matchIndex = i + break + } + } + + if matchIndex < 0 { + trainingWriteError(w, http.StatusNotFound, "feedback not found") + return + } + + old := items[matchIndex] + + updated := old + updated.Accepted = req.Accepted + updated.Notes = strings.TrimSpace(req.Notes) + + if req.Accepted { + updated.Correction = nil + } else { + updated.Correction = req.Correction + } + + items[matchIndex] = updated + + if err := trainingWriteAnnotations(root, items); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + sample, sampleErr := trainingReadSample(root, req.SampleID) + if sampleErr != nil { + sample = &TrainingSample{ + SampleID: old.SampleID, + FrameURL: old.FrameURL, + SourceFile: old.SourceFile, + SourcePath: old.SourcePath, + SourceSizeBytes: old.SourceSizeBytes, + Second: old.Second, + CreatedAt: old.CreatedAt, + Prediction: old.Prediction, + } + } + + trainingDeleteDetectorSample(root, req.SampleID) + + detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{ + SampleID: req.SampleID, + Accepted: req.Accepted, + Correction: req.Correction, + Notes: req.Notes, + }) + + if len(detectorBoxes) > 0 { + if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { + appLogln("⚠️ detector sample update failed:", err) + } + } + + trainingWriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "item": updated, + }) +} + +func trainingSkipHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req TrainingSkipRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + trainingWriteError(w, http.StatusBadRequest, "invalid json") + return + } + + sampleID := strings.TrimSpace(req.SampleID) + if sampleID == "" { + trainingWriteError(w, http.StatusBadRequest, "sampleId missing") + return + } + + if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { + trainingWriteError(w, http.StatusBadRequest, "invalid sampleId") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Aus Uncertain-Queue entfernen, falls es dort noch liegt. + if err := trainingRemoveSampleFromUncertainQueue(root, sampleID); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Sample + Frame löschen. + trainingDeleteSampleFiles(root, sampleID) + + trainingWriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "sampleId": sampleID, + }) +} + func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool { imageExts := map[string]bool{ ".jpg": true, @@ -1840,29 +2642,43 @@ func trainingCreateNextSample() (*TrainingSample, error) { return sample, nil } -func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { - const attempts = 8 - const stepsPerAttempt = 4 - - totalSteps := attempts*stepsPerAttempt + 1 - +func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) { root, err := trainingRootDir() if err != nil { return nil, err } - var best *TrainingSample - bestScore := -1.0 + candidates, err := trainingReadValidUncertainCandidates(root) + if err != nil { + return nil, err + } + + // Ziel: + // Vor jeder Auswahl sollen wieder 5 Kandidaten verglichen werden. + // Wenn noch 4 aus der Queue übrig sind, wird genau 1 neues Bild extrahiert. + // Wenn keine Queue existiert, wird ein kompletter 5er-Batch aufgebaut. + missing := trainingUncertainCandidateCount - len(candidates) + if missing < 1 { + // Trotzdem mindestens 1 neues Bild nachziehen, + // damit die Auswahl nach jedem Speichern frisch bleibt. + missing = 1 + } + + const stepsPerCandidate = 4 + totalSteps := missing*stepsPerCandidate + 1 + errs := []string{} - for i := 0; i < attempts; i++ { + for i := 0; i < missing; i++ { attempt := i + 1 - stepStart := i*stepsPerAttempt + 1 + stepStart := i*stepsPerCandidate + 1 - prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, attempts) + prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, missing) - sample, err := trainingCreateNextSampleWithProgressRange( + candidate, err := trainingCreateUncertainCandidateWithProgress( + root, startedAtMs, + requestID, stepStart, totalSteps, prefix, @@ -1871,45 +2687,36 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*Training if err != nil { errs = append(errs, err.Error()) - publishAnalysisStep( + trainingPublishAnalysisStep( + requestID, startedAtMs, - stepStart+stepsPerAttempt-1, + stepStart+stepsPerCandidate-1, totalSteps, "", - fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, attempts), + fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, missing), ) continue } - score := trainingPredictionUncertaintyScore(sample.Prediction) + candidates = append(candidates, *candidate) - publishAnalysisStep( + trainingPublishAnalysisStep( + requestID, startedAtMs, - stepStart+stepsPerAttempt-1, + stepStart+stepsPerCandidate-1, totalSteps, - sample.SourceFile, + candidate.sample.SourceFile, fmt.Sprintf( "Kandidat %d/%d bewertet · Unsicherheit %.0f%%", attempt, - attempts, - score*100, + missing, + candidate.score*100, ), ) - - if score > bestScore { - if best != nil { - trainingDeleteSampleFiles(root, best.SampleID) - } - - best = sample - bestScore = score - } else { - trainingDeleteSampleFiles(root, sample.SampleID) - } } - if best == nil { + if len(candidates) == 0 { if len(errs) > 0 { return nil, errors.New(strings.Join(errs, "; ")) } @@ -1917,15 +2724,42 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*Training return nil, errors.New("keine unsicheren Trainingskandidaten gefunden") } - publishAnalysisStep( + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt + } + + return candidates[i].score > candidates[j].score + }) + + best := candidates[0] + remaining := candidates[1:] + + if len(remaining) > trainingUncertainCandidateCount-1 { + remaining = remaining[:trainingUncertainCandidateCount-1] + } + + if err := trainingWriteUncertainCandidateQueue(root, remaining); err != nil { + return nil, err + } + + trainingPublishAnalysisStep( + requestID, startedAtMs, totalSteps, totalSteps, - best.SourceFile, - fmt.Sprintf("Unsicherer Kandidat gewählt · Score %.0f%%", bestScore*100), + best.sample.SourceFile, + fmt.Sprintf( + "Unsicherster Kandidat gewählt · Score %.0f%% · Fenster %d/%d", + best.score*100, + len(remaining)+1, + trainingUncertainCandidateCount, + ), ) - return best, nil + best.sample.UncertaintyScore = best.score + + return best.sample, nil } func trainingDeleteSampleFiles(root string, sampleID string) { @@ -2015,9 +2849,10 @@ func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 { return clamp01(avg + boxBonus + lowConfidenceBonus) } -func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { +func trainingCreateNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) { return trainingCreateNextSampleWithProgressRange( startedAtMs, + requestID, 1, 4, "", @@ -2026,12 +2861,14 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e func trainingCreateNextSampleWithProgressRange( startedAtMs int64, + requestID string, stepStart int, stepTotal int, prefix string, ) (*TrainingSample, error) { publishStep := func(localStep int, sourceFile string, message string) { - publishAnalysisStep( + trainingPublishAnalysisStep( + requestID, startedAtMs, stepStart+localStep-1, stepTotal, @@ -2124,24 +2961,74 @@ func trainingCreateNextSampleWithProgressRange( func trainingPickRandomVideo(doneDir string) (string, error) { extOK := map[string]bool{ - ".mp4": true, - ".mkv": true, - ".webm": true, - ".mov": true, - ".avi": true, + ".mp4": true, + } + + doneDir = strings.TrimSpace(doneDir) + if doneDir == "" { + return "", errors.New("doneDir ist leer") + } + + doneAbs, err := filepath.Abs(doneDir) + if err != nil { + return "", err } var files []string - err := filepath.WalkDir(doneDir, func(path string, d os.DirEntry, err error) error { + err = filepath.WalkDir(doneAbs, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + + rel, err := filepath.Rel(doneAbs, path) if err != nil { return nil } + + rel = filepath.Clean(rel) + + // Root "done" selbst erlauben. + if rel == "." { + return nil + } + + parts := strings.Split(rel, string(os.PathSeparator)) + top := strings.ToLower(strings.TrimSpace(parts[0])) + name := strings.ToLower(strings.TrimSpace(d.Name())) + if d.IsDir() { - name := strings.ToLower(d.Name()) - if name == ".trash" || name == "training" || name == "generated" { + // Nur diese Bereiche fürs Trainingsbild: + // - done/ + // - done/keep/... + // + // Alles andere unter done wird ignoriert, z.B.: + // .postwork_tmp, .trash, generated, training, sonstige Temp-Ordner. + if top != "keep" { return filepath.SkipDir } + + // Innerhalb von keep trotzdem versteckte/temporäre Ordner überspringen. + if name == ".trash" || + name == ".postwork_tmp" || + name == "training" || + name == "generated" || + strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + + return nil + } + + // Dateien nur direkt in done/ oder unter done/keep/... erlauben. + if len(parts) > 1 && top != "keep" { + return nil + } + + // Keine versteckten/temp-Dateien verwenden. + if strings.HasPrefix(name, ".") || + strings.Contains(name, ".tmp.") || + strings.Contains(name, ".part") { return nil } @@ -2149,6 +3036,7 @@ func trainingPickRandomVideo(doneDir string) (string, error) { if extOK[ext] { files = append(files, path) } + return nil }) @@ -2157,7 +3045,7 @@ func trainingPickRandomVideo(doneDir string) (string, error) { } if len(files) == 0 { - return "", errors.New("keine Videos im doneDir gefunden") + return "", errors.New("keine Videos in done oder done/keep gefunden") } return files[rand.Intn(len(files))], nil @@ -2630,6 +3518,24 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr return os.WriteFile(labelPath, []byte(strings.Join(lines, "\n")+"\n"), 0644) } +func trainingDeleteDetectorSample(root string, sampleID string) { + sampleID = strings.TrimSpace(sampleID) + if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { + return + } + + for _, split := range []string{"train", "val"} { + labelsDir := filepath.Join(root, "detector", "dataset", "labels", split) + imagesDir := filepath.Join(root, "detector", "dataset", "images", split) + + _ = os.Remove(filepath.Join(labelsDir, sampleID+".txt")) + + for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} { + _ = os.Remove(filepath.Join(imagesDir, sampleID+ext)) + } + } +} + func trainingEnsureDetectorValidationSample(root string) error { trainImages := filepath.Join(root, "detector", "dataset", "images", "train") trainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") @@ -2929,6 +3835,29 @@ func trainingAppendAnnotation(root string, annotation TrainingAnnotation) error return nil } +func trainingWriteAnnotations(root string, items []TrainingAnnotation) error { + path := filepath.Join(root, "feedback.jsonl") + tmpPath := path + ".tmp" + + var b strings.Builder + + for _, item := range items { + line, err := json.Marshal(item) + if err != nil { + return err + } + + b.Write(line) + b.WriteByte('\n') + } + + if err := os.WriteFile(tmpPath, []byte(b.String()), 0644); err != nil { + return err + } + + return os.Rename(tmpPath, path) +} + func trainingCountAnnotations(path string) (int, error) { b, err := os.ReadFile(path) if err != nil { diff --git a/backend/yolo26n.pt b/backend/yolo26n.pt new file mode 100644 index 0000000..be48188 Binary files /dev/null and b/backend/yolo26n.pt differ diff --git a/cert/mkcert-v1.4.4-windows-amd64.exe b/cert/mkcert-v1.4.4-windows-amd64.exe new file mode 100644 index 0000000..c9cde49 Binary files /dev/null and b/cert/mkcert-v1.4.4-windows-amd64.exe differ diff --git a/cert/recorder-cert.pem b/cert/recorder-cert.pem new file mode 100644 index 0000000..33c3442 --- /dev/null +++ b/cert/recorder-cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEbjCCAtagAwIBAgIRAKGvq9b44yCGC32q8PwkueowDQYJKoZIhvcNAQELBQAw +gYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwnVEVH +RFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMTcwNQYDVQQDDC5t +a2NlcnQgVEVHRFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMB4X +DTI2MDUwODEwMDQzN1oXDTI4MDgwODEwMDQzN1owWzEnMCUGA1UEChMebWtjZXJ0 +IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdURUdEU1NEXFJvdGhl +ckBMMTRQQkJLOTUxMDAwMDYgKFJvdGhlcikwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDDghqU3igC3sNP2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4K +mJ6enU5zrGWI4+T0IHfbo737rvgk6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGs +zIQGJeI4TIeQ7sa7krCpMB/e1YxR1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3N +CC2OgxVYSh/hR9XNIIEPMoioQIYhle41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot ++aBlRjbxgOarLyST3XdtjAfC6CqxnzprX1LfznuULjatoS+pFDBDoy2B13xwa9Br +f+Xy3CswmSO+AzNomWfbeBkJnl6xb3Tb1urrAgMBAAGjfDB6MA4GA1UdDwEB/wQE +AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBR0Zph7hFKq4GpE +XO0y3U+T0iFoQDAyBgNVHREEKzApgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAA +AAAAAAAAAAGHBAoAARkwDQYJKoZIhvcNAQELBQADggGBAJShhgQS+9cwuVOJUIW+ +uy9t1ZAjR9qTd31kHdK1vu294GiavrG8RD+92BRujG4Fq1ambvqcu9sUaC8+0HAP +cQvdmnbTxG0ZHkGP7VVj3Poee0jpsVAVtV3aVwKxyO/E7Mv/nEKTqNfnECbEuiqC +tOJ8mI3E9tf2/ljzq2PqvfIPFBBtUT2lihNAFXM/06Nzoa1NPdfYd58N+VBFSOSq +zEM4fmpcqMThZGKGBoWmHwTE5Mw6mZFLyxHJpOFu5wNzyPJGes5o/F0ot5n+ZGah +GbYaGmhEedOxC3/WXRtexJmZ0jrB8Hkx+c/4h6+0HKU/PelrFJS+c2d6h8YGXyhv +pUsfyyoQzx7rSgyLfJqXK+52ZtnWNc0yVVMyKJwjvhv4mcP9l/q3UGR/i7xkD5Pc +2bLgM+wc8nKI7Yd/xejLpQDC0b/P1XibT1T3GRjiC/eu64G/uFOF+m3P5XvmF8JO +0cj8fXwUCq+TVD+22rFmtT+2VYkf8RUsC48Ou+PHccmDyw== +-----END CERTIFICATE----- diff --git a/cert/recorder-key.pem b/cert/recorder-key.pem new file mode 100644 index 0000000..67692b1 --- /dev/null +++ b/cert/recorder-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDDghqU3igC3sNP +2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4KmJ6enU5zrGWI4+T0IHfbo737rvgk +6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGszIQGJeI4TIeQ7sa7krCpMB/e1YxR +1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3NCC2OgxVYSh/hR9XNIIEPMoioQIYh +le41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot+aBlRjbxgOarLyST3XdtjAfC6Cqx +nzprX1LfznuULjatoS+pFDBDoy2B13xwa9Brf+Xy3CswmSO+AzNomWfbeBkJnl6x +b3Tb1urrAgMBAAECggEBAI31EBv72w2KdkKroot+vROCz6quMAdNvgezQif7VcHY +fuBN7EcBXltJWUeAbBEjeIESejUPBcmT2DXlF841CC5lB4VCaeV5lsreE9IsNYBf +CakIwLmZnltgcovydZT063QTJRcUDHAems5pjw76zMLKO+h9GEiMoUxFb3/atv3e +K5HE+uUd6JcPU0q91S6TxqvS9gH30OOkwH6Kl4tzWauQAKg/sVzYafbyTAuEpiMO +jLLLAn/sOmiL4knnRVu1FK4aZSH+t9BN9YuwfucXFn9jFgPtIJ0bF1GuqnwIeDI5 +BTNanHfaZRHD0+FbS0KbhARIFgDveoGrdoEtou0eDOECgYEA5dJqUvk96f7zCrxD +eSuUl1MvOjydY3GacfjevZoTxJDqh4Wt2wgLYJc4XB7tI02ci7NncecG8qPfqWq7 +aFjQAGAYdR+XvE7R2m3OFK9WbRX4cH6b+l7mSq0eOvcAAajn4RuNr/il+7Opw7zX +eKUB/pnW36B+ethdr5l+kDUQb8MCgYEA2ccanIT2QAxJb5j0F287NGhaTPuWfxcX +2T53I2fIwMiell0YXjwsKwKpckubRB2f5X4b9QJifUTmtxHky/Tx4H9OVhCaxumw +edol5YNzZaP/Tx8m2mqpyN2WPbezIFeA+GNcmDAxEo/NT2B64OVCHWKosndvb+Yk +GFmVb+g9zbkCgYEA1YFJLZRHJJ+pgour02HdRUgOU/gD72KWrNMbeuEtBCvs9cIG +5bjveOiDf3FrtKRhjpc4vuR12+zJ2EZDnIkFk5OypPyYpmRDKL1h+m15yRXkG/5D +QbHwF+gEcZsN8nzMDqDeXGCPMuqSCDnjoz0IQVMB//bGCbIANyZOIgJqJqkCgYEA +vHc+ZG383fjEJLvtoco1JmmYnD6uQ1Ys4WjZmd5bMdtswxvV1tekMaSgF7WurQgm +NGkqsKJbsaVLNOtbYdac7He/x2OfTr02aH2Nhk54M2H1tPd0nFjqjlaVitvLPRX9 +GviCTYKHNVUVjLgmHzLIQL382FXcLq6wVhJQ7QPDWKECgYA6ZmdB8waN2SFOJRnj +5zWTgdImicl4yCu8PaEyloO9whYSdYH8zU3K+FKaowXGNInp3BGCmP9TUwoG8Iwx +Ug5d55P2FgwnhCi59Y6dMXih1p94BuQ0vcWPBqztofl7lZbBEnjwzKVycFvcyNFX +QFb/UA4/CfQli9ciDtlqzZt8SA== +-----END PRIVATE KEY----- diff --git a/frontend/.env b/frontend/.env deleted file mode 100644 index f05456a..0000000 --- a/frontend/.env +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL="file:./prisma/models.db" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index ef37c30..52d9c66 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,11 @@ + - App + Recorder
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f221583..4bd5cc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", + "@simplewebauthn/browser": "^13.3.0", "@tailwindcss/vite": "^4.1.18", "flag-icons": "^7.5.0", "prop-types": "^15.8.1", @@ -1466,6 +1467,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", diff --git a/frontend/package.json b/frontend/package.json index 559236b..d2b8836 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", + "@simplewebauthn/browser": "^13.3.0", "@tailwindcss/vite": "^4.1.18", "flag-icons": "^7.5.0", "prop-types": "^15.8.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 47a1cc4..216f459 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,9 @@ import { Squares2X2Icon, Cog6ToothIcon, BeakerIcon, + CircleStackIcon, + ArrowRightOnRectangleIcon, + PlayIcon, } from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' @@ -129,12 +132,16 @@ type TaskStateEvent = { type AnalysisProgressEvent = { type?: 'analysis_progress' + scope?: string + requestId?: string + analysisRequestId?: string running?: boolean phase?: 'starting' | 'running' | 'done' | 'error' | string progress?: number current?: number total?: number file?: string + sourceFile?: string message?: string error?: string startedAtMs?: number @@ -658,12 +665,41 @@ function TrainingBeakerIcon(props: React.ComponentProps) { ) } -export default function App() { +function normalizeTitleProgress(value: unknown): number | null { + const n = Number(value) + if (!Number.isFinite(n)) return null + + // Backend-Training kommt als 0..100. + // Analysis-Progress kommt als 0..1, wird aber für den Title nicht mehr benutzt. + const normalized = n > 1 ? n / 100 : n + + return Math.max(0, Math.min(1, normalized)) +} + +export default function App() { const [trainingTabRunning, setTrainingTabRunning] = useState(false) + const [trainingTitleProgress, setTrainingTitleProgress] = useState(null) + const [trainingImageExpanded, setTrainingImageExpanded] = useState(false) const [splitProgressByFile, setSplitProgressByFile] = useState>({}) const splitProgressClearTimersRef = useRef>({}) + useEffect(() => { + const baseTitle = 'Recorder' + + if (trainingTabRunning) { + const progress = + typeof trainingTitleProgress === 'number' && Number.isFinite(trainingTitleProgress) + ? ` ${Math.round(Math.max(0, Math.min(1, trainingTitleProgress)) * 100)}%` + : '' + + document.title = `${baseTitle} · Training${progress}` + return + } + + document.title = baseTitle + }, [trainingTabRunning, trainingTitleProgress]) + useEffect(() => { return () => { for (const t of Object.values(splitProgressClearTimersRef.current)) { @@ -1119,6 +1155,10 @@ export default function App() { if (cancelled || !res.ok || !data) return setTrainingTabRunning(Boolean(data?.training?.running)) + + setTrainingTitleProgress( + normalizeTitleProgress(data?.training?.progress ?? data?.progress) + ) } catch { // ignore } @@ -2843,13 +2883,21 @@ export default function App() { if (msg?.type !== 'analysis_progress') return - window.dispatchEvent( - new CustomEvent('app:sse:analysis', { - detail: msg, - }) - ) + const scope = String(msg?.scope ?? '').trim() - const file = baseName(String(msg?.file ?? '').trim()) + if (scope === 'training') { + // Nur an den TrainingTab weiterreichen. + // Wichtig: Bildanalyse darf den Browser-Title NICHT auf "Training ..." setzen. + window.dispatchEvent( + new CustomEvent('app:sse:analysis', { + detail: msg, + }) + ) + + return + } + + const file = baseName(String(msg?.file ?? msg?.sourceFile ?? '').trim()) if (!file) return const phase = String(msg?.phase ?? '').trim().toLowerCase() @@ -2933,7 +2981,15 @@ export default function App() { if (data?.type !== 'training_status') return - setTrainingTabRunning(Boolean(data?.training?.running)) + const running = Boolean(data?.training?.running) + + setTrainingTabRunning(running) + + setTrainingTitleProgress( + running + ? normalizeTitleProgress(data?.training?.progress ?? data?.progress) + : null + ) window.dispatchEvent( new CustomEvent('app:sse:training', { @@ -3322,12 +3378,12 @@ export default function App() { }, [doneCount]) const handleDeleteJobWithUndo = useCallback( - async (job: RecordJob): Promise => { + async (job: RecordJob): Promise => { const file = baseName(job.output || '') if (!file) return try { - const data = await runFinishedFileAction<{ undoToken?: string }>({ + const data = await runFinishedFileAction<{ undoToken?: string; from?: string }>({ kind: 'delete', file, run: () => @@ -3358,7 +3414,9 @@ export default function App() { }) const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' - return undoToken ? { undoToken } : {} + const from = typeof data?.from === 'string' ? data.from : undefined + + return undoToken ? { undoToken, from } : {} } catch { return } @@ -3848,6 +3906,14 @@ export default function App() { } }, [autoAddEnabled, autoStartEnabled, startUrl]) + const isTrainingTab = selectedTab === 'training' + + useEffect(() => { + if (!isTrainingTab) { + setTrainingImageExpanded(false) + } + }, [isTrainingTab]) + if (!authChecked) { return
Lade…
} @@ -3856,17 +3922,38 @@ export default function App() { return } - const headerInfoPanelClass = - 'rounded-lg border border-gray-200/70 bg-white/45 px-2.5 py-1.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 sm:flex sm:h-[47px] sm:items-center sm:rounded-xl sm:px-3 sm:py-0' + const headerShellClass = + 'rounded-2xl border border-gray-200/70 bg-white/65 p-2.5 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] sm:p-3' - const headerStatBadgeClass = - 'inline-flex h-6 items-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-2 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100 sm:h-7 sm:gap-1.5 sm:px-2.5 sm:text-[11px]' + const headerStatClass = + 'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200' - const mobileHeaderStatBadgeClass = - 'inline-flex h-6 min-w-[3.1rem] items-center justify-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-1.5 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100' + const headerActionClass = + 'h-9 shrink-0 rounded-xl px-3 text-sm' + + const headerStatItems = [ + { + label: 'Online', + value: onlineModelsCount, + icon: SignalIcon, + }, + { + label: 'Watched', + value: onlineWatchedModelsCount, + icon: EyeIcon, + }, + { + label: 'Favoriten', + value: onlineFavCount, + icon: StarIcon, + }, + { + label: 'Likes', + value: onlineLikedCount, + icon: HeartIcon, + }, + ] - const isTrainingTab = selectedTab === 'training' - return (
-
-
-
-
- {/* Desktop */} -
-
-
-

- Recorder -

+
+
+
+
+
+
+
+
+
+
-
- - - {onlineModelsCount} - - - - - {onlineWatchedModelsCount} - - - - - {onlineFavCount} - - - - - {onlineLikedCount} - -
- -
- -
- -
-
-
-
- - {/* Mobile */} -
-
-
-
-

+

Recorder

-
- - - {onlineModelsCount} - +
+ {headerStatItems.map((item) => { + const Icon = item.icon - - - {onlineWatchedModelsCount} - + return ( + + + ) + })}
-
+
-
+
{showPerfMon ? ( ) : null} -
+
-
-
- {showPerfMon ? ( - +
+
+ + } }) => + setSourceUrl(e.target.value) + } + selectAllOnFocus + selectAllOnMouseDown + placeholder="Stream-URL einfügen…" + /> +
+ + +
+ + {error ? ( +
+
+
{error}
+ + +
+
) : null} - + {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? ( +
+ ⚠️ Für Chaturbate werden die Cookies cf_clearance und sessionId benötigt. +
+ ) : null} - -
-
+ {busy ? ( + + ) : null} -
-
- - } }) => setSourceUrl(e.target.value)} - selectAllOnFocus - selectAllOnMouseDown - placeholder="https://…" - /> -
- - -
- - {error ? ( -
-
-
{error}
- +
+
- ) : null} - - {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? ( -
- ⚠️ Für Chaturbate werden die Cookies cf_clearance und sessionId benötigt. -
- ) : null} - - {busy ? ( -
- -
- ) : null} - -
-
-
-
- -
-
-
+ ) : null} {selectedTab === 'categories' ? : null} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 73d0804..2be2cc4 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -26,7 +26,20 @@ function cn(...parts: Array) { } const base = - 'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed' + 'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 ' + + 'disabled:cursor-not-allowed disabled:saturate-0 disabled:opacity-60 disabled:hover:brightness-100' + +const disabledPrimary = + 'disabled:!bg-gray-300 disabled:!text-gray-600 disabled:shadow-none disabled:hover:!bg-gray-300 ' + + 'dark:disabled:!bg-white/10 dark:disabled:!text-white/40 dark:disabled:hover:!bg-white/10' + +const disabledSecondary = + 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + + 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5' + +const disabledSoft = + 'disabled:bg-gray-100 disabled:text-gray-500 disabled:inset-ring-gray-200 disabled:shadow-none disabled:hover:bg-gray-100 ' + + 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:hover:bg-white/5' const roundedMap = { sm: 'rounded-sm', @@ -44,72 +57,77 @@ const sizeMap: Record = { const colorMap: Record> = { indigo: { primary: - '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' + - 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500', + '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' + + 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' + + disabledPrimary, secondary: - 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' + - 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' + - 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5', + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' + + disabledSecondary, soft: - 'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' + - 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30', + 'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/20 hover:inset-ring-indigo-500/25 ' + + 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' + + disabledSoft, }, blue: { primary: - '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' + - 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500', + '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-500 focus-visible:outline-blue-600 ' + + 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' + + disabledPrimary, secondary: - 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' + - 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' + - 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5', + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' + + disabledSecondary, soft: - 'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' + - 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30', + 'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/20 hover:inset-ring-blue-500/25 ' + + 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' + + disabledSoft, }, emerald: { primary: - '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' + - 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500', + '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-500 focus-visible:outline-emerald-600 ' + + 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' + + disabledPrimary, secondary: - 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' + - 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' + - 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5', + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' + + disabledSecondary, soft: - 'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' + - 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30', + 'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/20 hover:inset-ring-emerald-500/25 ' + + 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' + + disabledSoft, }, red: { primary: - '!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' + - 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500', + '!bg-red-600 !text-white shadow-sm hover:!bg-red-500 focus-visible:outline-red-600 ' + + 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' + + disabledPrimary, secondary: - 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' + - 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' + - 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5', + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' + + disabledSecondary, soft: - 'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' + - 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30', + 'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/20 hover:inset-ring-red-500/25 ' + + 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' + + disabledSoft, }, amber: { primary: - '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' + - 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500', + '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-400 focus-visible:outline-amber-500 ' + + 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' + + disabledPrimary, secondary: - 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' + - 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + - 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' + - 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5', + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' + + disabledSecondary, soft: - 'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' + - 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30', + 'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/18 hover:inset-ring-amber-500/25 ' + + 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/25 ' + + disabledSoft, }, } @@ -179,4 +197,4 @@ export default function Button({ {trailingIcon && !isLoading && {trailingIcon}} ) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index b9b25d6..2987de9 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -543,19 +543,11 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none if (!isPostworkJob(job)) return 'none' if (isTerminalStatus(anyJ?.status)) return 'none' - if (pwState === 'queued') return 'queued' + // 1) Server-Status ist die Wahrheit if (pwState === 'running') return 'running' + if (pwState === 'queued') return 'queued' - if ( - phase === 'postwork' || - phase === 'probe' || - phase === 'remuxing' || - phase === 'moving' || - phase === 'assets' - ) { - return 'running' - } - + // 2) Queue-Positionsdaten aus dem Backend if ( typeof pw?.position === 'number' && Number.isFinite(pw.position) && @@ -573,10 +565,28 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none return 'running' } + // 3) Wenn ein PostWorkKey existiert, aber noch kein running-Status da ist: + // NICHT als running anzeigen, sondern als queued. if (hasPwKey) { return 'queued' } + // 4) Nur echte konkrete Arbeitsphasen sind running. + // "postwork" alleine bedeutet nur: Job ist im Nacharbeitsbereich. + if ( + phase === 'probe' || + phase === 'remuxing' || + phase === 'moving' || + phase === 'assets' || + phase === 'analyze' + ) { + return 'running' + } + + if (phase === 'postwork') { + return 'queued' + } + if (anyJ.endedAt || job.endedAt) return 'queued' return 'none' diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 21e2a36..e4bb021 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -72,7 +72,10 @@ type Props = { onOpenPlayer: (job: RecordJob, startAtSec?: number) => void onDeleteJob?: ( job: RecordJob - ) => void | { undoToken?: string } | Promise + ) => + | void + | { undoToken?: string; from?: string } + | Promise onToggleHot?: ( job: RecordJob ) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise @@ -490,17 +493,45 @@ function mergeMetaPreferDone( const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {} const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {} + const incomingHighlights = isPlainObject(incomingAnalysis.highlights) + ? incomingAnalysis.highlights + : {} + + const existingHighlights = isPlainObject(existingAnalysis.highlights) + ? existingAnalysis.highlights + : {} + + const incomingAi = isPlainObject(incomingAnalysis.ai) + ? incomingAnalysis.ai + : {} + + const existingAi = isPlainObject(existingAnalysis.ai) + ? existingAnalysis.ai + : {} + merged.analysis = { ...incomingAnalysis, ...existingAnalysis, - ai: - isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0 + + highlights: + Object.keys(existingHighlights).length > 0 ? { - ...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}), - ...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}), + ...incomingHighlights, + ...existingHighlights, } - : isPlainObject(incomingAnalysis.ai) - ? incomingAnalysis.ai + : Object.keys(incomingHighlights).length > 0 + ? incomingHighlights + : undefined, + + // Altbestand weiter unterstützen. + ai: + Object.keys(existingAi).length > 0 + ? { + ...incomingAi, + ...existingAi, + } + : Object.keys(incomingAi).length > 0 + ? incomingAi : undefined, } as any @@ -924,6 +955,7 @@ function buildCompletedPostworkSummaryFromMeta( ) const aiObject = + meta?.analysis?.highlights ?? meta?.analysis?.ai ?? meta?.ai @@ -965,7 +997,7 @@ function buildCompletedPostworkSummaryFromMeta( ) const hasAiAnalysis = - readCompletedFlag(meta, 'analyze', 'analysis', 'ai') || + readCompletedFlag(meta, 'analyze', 'analysis', 'ai', 'highlights') || hasDoneHistoryForPhase(meta, 'enrich', 'analyze') || hasNonEmptyObject(aiObject) || hasAiHits || @@ -2463,7 +2495,15 @@ export default function FinishedDownloads({ showOnlyCorrupt, ]) - const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage + const finishedDownloadsLoading = + allDoneJobsLoading && globalFilterActive && allDoneJobs === null + + const totalItemsForPagination = + finishedDownloadsLoading + ? 0 + : effectiveAllMode + ? visibleRows.length + : doneTotalPage const pageRows = useMemo(() => { if (effectiveAllMode) { @@ -2479,8 +2519,15 @@ export default function FinishedDownloads({ return visibleRows }, [visibleRows, page, effectivePageSize, effectiveAllMode, view]) - const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 - const emptyByFilter = globalFilterActive && visibleRows.length === 0 + const emptyFolder = + !finishedDownloadsLoading && + !effectiveAllMode && + totalItemsForPagination === 0 + + const emptyByFilter = + !finishedDownloadsLoading && + globalFilterActive && + visibleRows.length === 0 const selectedCount = useMemo(() => { return selectionStore.getCount() @@ -3104,9 +3151,10 @@ export default function FinishedDownloads({ selectionStore.deselect(key) const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' + const from = typeof result?.from === 'string' ? result.from : undefined if (undoToken) { - setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key }) + setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from }) } else { setLastAction(null) } @@ -3304,7 +3352,7 @@ export default function FinishedDownloads({ unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) - emitCountHint(+1) + emitCountHint(includeKeep ? 0 : +1) setLastAction(null) return } @@ -3686,29 +3734,6 @@ export default function FinishedDownloads({ // effects // ----------------------------------------------------------------------------- - useEffect(() => { - if (!showOnlyCorrupt) return - - for (const job of viewRows) { - const file = baseName(job.output || '') - if (!file) continue - - const state = readDownloadIntegrityState((job as any)?.meta) - if (state !== 'unchecked') continue - - if (integrityMetaPendingRef.current.has(file)) continue - integrityMetaPendingRef.current.add(file) - - void refreshDoneMetaForFile(file).finally(() => { - integrityMetaPendingRef.current.delete(file) - }) - } - }, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile]) - - useEffect(() => { - onPageChange(1) - }, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange]) - useEffect(() => { if (!globalFilterActive) { setAllDoneJobs(null) @@ -3743,6 +3768,29 @@ export default function FinishedDownloads({ } }, [globalFilterActive, loadAllDoneJobs]) + useEffect(() => { + if (!showOnlyCorrupt) return + + for (const job of viewRows) { + const file = baseName(job.output || '') + if (!file) continue + + const state = readDownloadIntegrityState((job as any)?.meta) + if (state !== 'unchecked') continue + + if (integrityMetaPendingRef.current.has(file)) continue + integrityMetaPendingRef.current.add(file) + + void refreshDoneMetaForFile(file).finally(() => { + integrityMetaPendingRef.current.delete(file) + }) + } + }, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile]) + + useEffect(() => { + onPageChange(1) + }, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange]) + useEffect(() => { if (view !== 'gallery') return @@ -4845,7 +4893,8 @@ export default function FinishedDownloads({ @@ -5231,7 +5280,24 @@ export default function FinishedDownloads({
- {emptyFolder ? ( + {finishedDownloadsLoading ? ( + +
+
+ +
+ +
+
+ Abgeschlossene Downloads werden geladen… +
+
+ Bitte kurz warten, die Liste wird vom Backend geladen. +
+
+
+
+ ) : emptyFolder ? (
diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index a3c473f..099c869 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -664,14 +664,19 @@ export function ToyIcon(props: IconProps) { - + + + + + + ) @@ -1096,11 +1101,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Unbekannt', icon: UnknownContentIcon, }, - { - match: ['other', 'misc', 'miscellaneous'], - text: 'Andere', - icon: OtherContentIcon, - }, // People { match: ['person_female', 'female_person'], @@ -1524,10 +1524,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { return { match: [], text: 'Crop Top', icon: CropTopIcon } } - if (labelKey.includes('other') || labelKey.includes('misc')) { - return { match: [], text: 'Andere', icon: OtherContentIcon } - } - return null } diff --git a/frontend/src/components/ui/LoginPage.tsx b/frontend/src/components/ui/LoginPage.tsx index 70902e9..ca427a0 100644 --- a/frontend/src/components/ui/LoginPage.tsx +++ b/frontend/src/components/ui/LoginPage.tsx @@ -1,6 +1,21 @@ // frontend/src/components/ui/LoginPage.tsx import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react' +import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import Button from './Button' +import LoginShell from './login/LoginShell' +import ErrorAlert from './login/ErrorAlert' +import LoginForm from './login/LoginForm' +import TotpCodeInput from './login/TotpCodeInput' +import PasskeySetupPrompt from './login/PasskeySetupPrompt' +import { apiJSON, getNextFromLocation } from './login/loginApi' +import { + clearLoginState, + digitsToCode, + loadLoginState, + saveLoginState, + splitCodeToDigits, + type LoginStage, +} from './login/loginStorage' type Props = { onLoggedIn?: () => void | Promise @@ -11,11 +26,18 @@ type LoginResp = { totpRequired?: boolean } +type PasskeyFeedback = { + kind: 'info' | 'loading' | 'success' + text: string +} + type MeResp = { authenticated?: boolean pending2fa?: boolean totpEnabled?: boolean totpConfigured?: boolean + passkeyConfigured?: boolean + passkeyCount?: number } type SetupResp = { @@ -23,78 +45,14 @@ type SetupResp = { otpauth?: string } -async function apiJSON(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { credentials: 'include', ...init }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(text || `HTTP ${res.status}`) +function unwrapWebAuthnOptions(options: any): T { + const unwrapped = options?.publicKey ?? options + + if (!unwrapped?.challenge) { + throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.') } - return res.json() as Promise -} -function getNextFromLocation(): string { - try { - const u = new URL(window.location.href) - const next = u.searchParams.get('next') || '/' - // Sicherheitsgurt: nur relative Pfade zulassen - if (!next.startsWith('/')) return '/' - if (next.startsWith('/login')) return '/' - return next - } catch { - return '/' - } -} - -const LOGIN_STATE_KEY = 'recorder_login_state_v1' - -type PersistedLoginState = { - stage: 'login' | 'verify' | 'setup' - username: string - code: string - setupAuthUrl: string | null - setupSecret: string | null - setupInfo: string | null - ts: number -} - -function loadLoginState(): PersistedLoginState | null { - try { - const raw = sessionStorage.getItem(LOGIN_STATE_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as PersistedLoginState - // optional: nach 15 Minuten verwerfen - if (!parsed?.ts || Date.now() - parsed.ts > 15 * 60 * 1000) return null - return parsed - } catch { - return null - } -} - -function saveLoginState(s: PersistedLoginState) { - try { - sessionStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(s)) - } catch { - // ignore - } -} - -function clearLoginState() { - try { - sessionStorage.removeItem(LOGIN_STATE_KEY) - } catch { - // ignore - } -} - -function splitCodeToDigits(code: string): string[] { - const only = (code ?? '').replace(/\D/g, '').slice(0, 6) - const arr = only.split('') - while (arr.length < 6) arr.push('') - return arr -} - -function digitsToCode(d: string[]) { - return (d ?? []).join('') + return unwrapped as T } export default function LoginPage({ onLoggedIn }: Props) { @@ -106,20 +64,185 @@ export default function LoginPage({ onLoggedIn }: Props) { // 6x inputs const [codeDigits, setCodeDigits] = useState(['', '', '', '', '', '']) const codeInputsRef = useRef>([]) + const lastSubmittedTotpCodeRef = useRef('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - const [stage, setStage] = useState<'login' | 'verify' | 'setup'>('login') + const [stage, setStage] = useState('login') const [setupAuthUrl, setSetupAuthUrl] = useState(null) const [setupSecret, setSetupSecret] = useState(null) const [setupInfo, setSetupInfo] = useState(null) + const [passkeySupported, setPasskeySupported] = useState(false) + const [passkeyFeedback, setPasskeyFeedback] = useState(null) + const submittedOnceRef = useRef(false) const codeStr = useMemo(() => digitsToCode(codeDigits), [codeDigits]) + async function finishLogin() { + if (onLoggedIn) await onLoggedIn() + clearLoginState() + window.location.assign(nextPath || '/') + } + + async function continueAfterFullAuth() { + const me = await apiJSON('/api/auth/me', { cache: 'no-store' as any }) + + if (me?.authenticated && !me?.passkeyConfigured) { + setStage('passkeySetup') + return + } + + await finishLogin() + } + + useEffect(() => { + setPasskeySupported( + typeof window !== 'undefined' && + window.isSecureContext && + typeof window.PublicKeyCredential !== 'undefined' + ) + }, []) + + function passkeyErrorMessage(e: any) { + const raw = String(e?.message ?? e ?? '').trim() + + if (!raw) return 'Passkey konnte nicht eingerichtet werden.' + + if (raw.includes('passkey registration verify failed')) { + return ( + raw + + '\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.' + ) + } + + if ( + raw.includes('NotAllowedError') || + raw.toLowerCase().includes('not allowed') || + raw.toLowerCase().includes('aborted') + ) { + return 'Passkey-Einrichtung wurde abgebrochen oder vom Browser/Authenticator nicht erlaubt.' + } + + if ( + raw.includes('SecurityError') || + raw.toLowerCase().includes('relying party') || + raw.toLowerCase().includes('rp id') || + raw.toLowerCase().includes('origin') + ) { + return ( + raw + + '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.' + ) + } + + if (raw.includes('challenge expired')) { + return 'Die Passkey-Challenge ist abgelaufen. Bitte erneut versuchen.' + } + + return raw + } + + async function registerPasskey(): Promise { + if (busy) return false + + setBusy(true) + setError(null) + setPasskeyFeedback({ + kind: 'loading', + text: 'Starte Passkey-Einrichtung…', + }) + + try { + setPasskeyFeedback({ + kind: 'loading', + text: 'Hole WebAuthn-Challenge vom Backend…', + }) + + const options = await apiJSON('/api/auth/passkey/register/options', { + method: 'POST', + }) + + setPasskeyFeedback({ + kind: 'loading', + text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.', + }) + + const credential = await startRegistration({ + optionsJSON: unwrapWebAuthnOptions(options), + }) + + setPasskeyFeedback({ + kind: 'loading', + text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…', + }) + + await apiJSON<{ ok?: boolean }>('/api/auth/passkey/register/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }) + + setError(null) + setSetupInfo('Passkey wurde hinzugefügt.') + setPasskeyFeedback({ + kind: 'success', + text: 'Passkey wurde erfolgreich hinzugefügt.', + }) + return true + } catch (e: any) { + const message = passkeyErrorMessage(e) + + // Fehler immer nur über ErrorAlert anzeigen. + setError(message) + + // Fortschrittsbox im PasskeySetupPrompt entfernen, + // damit dort keine zweite rote Fehlermeldung entsteht. + setPasskeyFeedback(null) + + return false + } finally { + setBusy(false) + } + } + + async function registerPasskeyAndContinue() { + const ok = await registerPasskey() + if (!ok) return + + await finishLogin() + } + + async function loginWithPasskey() { + setBusy(true) + setError(null) + + try { + const options = await apiJSON('/api/auth/passkey/login/options', { + method: 'POST', + }) + + const credential = await startAuthentication({ + optionsJSON: unwrapWebAuthnOptions(options), + }) + + await apiJSON<{ ok?: boolean }>('/api/auth/passkey/login/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }) + + await finishLogin() + } catch (e: any) { + setError(e?.message ?? String(e)) + } finally { + setBusy(false) + } + } + function focusDigit(i: number) { const el = codeInputsRef.current[i] if (el) el.focus() @@ -128,11 +251,18 @@ export default function LoginPage({ onLoggedIn }: Props) { function clearAllDigits() { setCodeDigits(['', '', '', '', '', '']) submittedOnceRef.current = false + lastSubmittedTotpCodeRef.current = '' + setError(null) window.setTimeout(() => focusDigit(0), 0) } function setDigitAt(i: number, val: string) { const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer + + setError(null) + submittedOnceRef.current = false + lastSubmittedTotpCodeRef.current = '' + setCodeDigits((prev) => { const next = [...prev] next[i] = v @@ -150,6 +280,10 @@ export default function LoginPage({ onLoggedIn }: Props) { // falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen if (only.length > 1) { + setError(null) + submittedOnceRef.current = false + lastSubmittedTotpCodeRef.current = '' + setCodeDigits((prev) => { const next = [...prev] let k = i @@ -160,7 +294,7 @@ export default function LoginPage({ onLoggedIn }: Props) { } return next }) - submittedOnceRef.current = false + window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0) return } @@ -207,6 +341,10 @@ export default function LoginPage({ onLoggedIn }: Props) { if (!only) return ev.preventDefault() + setError(null) + submittedOnceRef.current = false + lastSubmittedTotpCodeRef.current = '' + setCodeDigits((prev) => { const next = [...prev] let k = i @@ -218,7 +356,6 @@ export default function LoginPage({ onLoggedIn }: Props) { return next }) - submittedOnceRef.current = false window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0) } @@ -265,15 +402,18 @@ export default function LoginPage({ onLoggedIn }: Props) { if (cancelled) return if (me?.authenticated) { - // ✅ eingeloggt: wenn 2FA noch NICHT konfiguriert → Setup zeigen if (!me?.totpConfigured) { setStage('setup') - // Setup-Infos laden (QR/otpauth) void ensure2FASetup() - } else { - clearLoginState() - window.location.assign(nextPath || '/') + return } + + if (!me?.passkeyConfigured) { + setStage('passkeySetup') + return + } + + await finishLogin() return } @@ -322,9 +462,7 @@ export default function LoginPage({ onLoggedIn }: Props) { return } - if (onLoggedIn) await onLoggedIn() - clearLoginState() - window.location.assign(nextPath || '/') + await continueAfterFullAuth() } catch (e: any) { setError(e?.message ?? String(e)) } finally { @@ -336,8 +474,15 @@ export default function LoginPage({ onLoggedIn }: Props) { const c = codeStr.trim() if (!/^\d{6}$/.test(c)) return + // Wichtig gegen Flackern: + // denselben falschen 6-stelligen Code nicht erneut automatisch senden. + if (lastSubmittedTotpCodeRef.current === c) return + + lastSubmittedTotpCodeRef.current = c + submittedOnceRef.current = true setBusy(true) setError(null) + try { await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', { method: 'POST', @@ -345,12 +490,20 @@ export default function LoginPage({ onLoggedIn }: Props) { body: JSON.stringify({ code: c }), }) - if (onLoggedIn) await onLoggedIn() - clearLoginState() - window.location.assign(nextPath || '/') + lastSubmittedTotpCodeRef.current = '' + await continueAfterFullAuth() } catch (e: any) { - submittedOnceRef.current = false - setError(e?.message ?? String(e)) + const message = String(e?.message ?? e ?? '') + + if (message.trim() === 'invalid code') { + setError('Der 2FA-Code ist ungültig.') + } else { + setError(message) + } + + // NICHT auf false setzen. + // Sonst triggert der Auto-submit-Effect denselben falschen Code direkt erneut. + submittedOnceRef.current = true } finally { setBusy(false) } @@ -377,6 +530,26 @@ export default function LoginPage({ onLoggedIn }: Props) { setError(e?.message ?? String(e)) } } + + async function skip2FASetup() { + setBusy(true) + setError(null) + + try { + await apiJSON<{ ok?: boolean }>('/api/auth/2fa/cancel-setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + } catch { + // Ignorieren: Überspringen soll nicht blockieren. + // Falls der Endpoint noch nicht vorhanden ist, geht der Login trotzdem weiter. + } finally { + setBusy(false) + } + + await finishLogin() + } // Auto-submit sobald 6 Ziffern befüllt sind (verify + setup) useEffect(() => { @@ -384,241 +557,182 @@ export default function LoginPage({ onLoggedIn }: Props) { if (stage !== 'verify' && stage !== 'setup') return if (!/^\d{6}$/.test(codeStr)) return if (submittedOnceRef.current) return + if (lastSubmittedTotpCodeRef.current === codeStr) return - submittedOnceRef.current = true void submit2FA() // eslint-disable-next-line react-hooks/exhaustive-deps }, [codeStr, stage, busy]) const Code6Inputs = ( -
- - -
- {codeDigits.map((d, i) => ( - { - codeInputsRef.current[i] = el - }} - value={d} - onChange={(e) => handleDigitChange(i, e.target.value)} - onKeyDown={(e) => handleDigitKeyDown(i, e)} - onPaste={(e) => handleDigitPaste(i, e)} - inputMode="numeric" - pattern="[0-9]*" - maxLength={1} - autoComplete={i === 0 ? 'one-time-code' : 'off'} - enterKeyHint={i === 5 ? 'done' : 'next'} - autoCapitalize="none" - autoCorrect="off" - disabled={busy} - className="h-12 w-12 rounded-lg text-center text-lg tabular-nums bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 - focus:outline-none focus:ring-2 focus:ring-indigo-500 - dark:bg-white/10 dark:text-white dark:ring-white/10" - aria-label={`2FA Ziffer ${i + 1}`} - /> - ))} -
-
+ ) return ( -
-