added log + bugfixes

This commit is contained in:
Linrador 2026-05-05 14:05:56 +02:00
parent 9cf9dfeb21
commit 96b81fc89d
98 changed files with 7988 additions and 2064 deletions

BIN
Icons/69.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/anus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Icons/bikini.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
Icons/blindfold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
Icons/blowjob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
Icons/bra.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
Icons/breasts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
Icons/buttocks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
Icons/buttplug.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
Icons/choker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
Icons/cowgirl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
Icons/crop-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
Icons/cunnilingus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
Icons/dildo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
Icons/doggy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/dress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
Icons/female.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
Icons/fingering.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
Icons/hand-job.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
Icons/handcuffs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
Icons/high-heels.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
Icons/hotpants.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
Icons/lingerie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
Icons/male.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
Icons/missionary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
Icons/other.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
Icons/panties.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
Icons/penis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
Icons/prone-bone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Icons/reverse-cowgirl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Icons/shower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
Icons/sitting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
Icons/skirt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
Icons/standing-doggy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
Icons/standing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
Icons/stockings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
Icons/strap-on.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/tongue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
Icons/towel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
Icons/toy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
Icons/unknown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
Icons/vagina.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

244
backend/ai_server.py Normal file
View File

@ -0,0 +1,244 @@
# backend\ai_server.py
import os
from typing import List, Optional
from fastapi import FastAPI
from pydantic import BaseModel
from ultralytics import YOLO
BODY_LABELS = {
"anus",
"ass",
"breasts",
"penis",
"tongue",
"pussy",
}
OBJECT_LABELS = {
"blindfold",
"buttplug",
"collar",
"dildo",
"handcuffs",
"shower",
"strapon",
"towel",
"vibrator",
}
CLOTHING_LABELS = {
"bikini",
"bra",
"dress",
"heels",
"hotpants",
"lingerie",
"panties",
"skirt",
"stockings",
"croptop",
}
POSITION_LABELS = {
"missionary",
"doggy",
"cowgirl",
"reverse_cowgirl",
"cunnilingus",
"prone_bone",
"standing",
"standing_doggy",
"spooning",
"sitting",
"facesitting",
"handjob",
"blowjob",
"toy_play",
"fingering",
"69",
"other",
}
class PredictBatchRequest(BaseModel):
paths: List[str]
detectorOnly: bool = False
imageSize: int = 640
model: Optional[str] = None
app = FastAPI()
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
DEFAULT_MODEL_PATH = BASE_DIR / "generated" / "training" / "detector" / "model" / "best.pt"
def resolve_model_path() -> str:
env_path = os.environ.get("YOLO_MODEL", "").strip()
if env_path:
p = Path(env_path)
if p.exists():
return str(p)
raise RuntimeError(f"YOLO_MODEL not found: {p}")
default_path = DEFAULT_MODEL_PATH
if default_path.exists():
return str(default_path)
raise RuntimeError(f"YOLO model not found: {default_path}")
_MODEL_PATH = resolve_model_path()
_DEVICE = os.environ.get("YOLO_DEVICE", "")
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
model = YOLO(_MODEL_PATH)
def scored(label: str, score: float) -> dict:
return {
"label": label,
"score": float(score),
}
def best_score(items: list[dict], label: str, score: float) -> None:
for item in items:
if item["label"] == label:
if score > item["score"]:
item["score"] = float(score)
return
items.append(scored(label, score))
def prediction_from_result(result) -> dict:
names = result.names or {}
boxes_out = []
body_parts = []
objects = []
clothing = []
sex_position = "unknown"
sex_position_score = 0.0
if result.boxes is not None:
xywhn = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.cpu().tolist()
conf_values = result.boxes.conf.cpu().tolist()
for box_xywhn, cls_id, conf in zip(xywhn, cls_values, conf_values):
label = str(names.get(int(cls_id), int(cls_id))).strip().lower()
score = float(conf)
if not label:
continue
cx, cy, w, h = [float(v) for v in box_xywhn]
x = max(0.0, min(1.0, cx - w / 2.0))
y = max(0.0, min(1.0, cy - h / 2.0))
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
boxes_out.append({
"label": label,
"score": score,
"x": x,
"y": y,
"w": w,
"h": h,
})
if label in BODY_LABELS:
best_score(body_parts, label, score)
if label in OBJECT_LABELS:
best_score(objects, label, score)
if label in CLOTHING_LABELS:
best_score(clothing, label, score)
if label in POSITION_LABELS and score > sex_position_score:
sex_position = label
sex_position_score = score
people_count = sum(
1 for box in boxes_out
if box["label"] in {"person", "person_male", "person_female", "person_unknown"}
)
male_count = sum(1 for box in boxes_out if box["label"] in {"person_male", "male_person"})
female_count = sum(1 for box in boxes_out if box["label"] in {"person_female", "female_person"})
unknown_count = max(0, people_count - male_count - female_count)
return {
"modelAvailable": True,
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
"peopleCount": people_count,
"maleCount": male_count,
"femaleCount": female_count,
"unknownCount": unknown_count,
"sexPosition": sex_position,
"sexPositionScore": sex_position_score,
"bodyPartsPresent": body_parts,
"objectsPresent": objects,
"clothingPresent": clothing,
"boxes": boxes_out,
}
@app.post("/predict-batch")
def predict_batch(req: PredictBatchRequest):
paths = [str(path).strip() for path in req.paths if str(path).strip()]
if not paths:
return {
"ok": False,
"predictions": [],
"error": "no paths supplied",
}
imgsz = int(req.imageSize or _IMGSZ or 640)
try:
results = model.predict(
source=paths,
imgsz=imgsz,
conf=_CONF,
batch=_BATCH,
device=_DEVICE or None,
half=_HALF,
verbose=False,
)
predictions = [prediction_from_result(result) for result in results]
return {
"ok": True,
"predictions": predictions,
}
except Exception as exc:
return {
"ok": False,
"predictions": [],
"error": str(exc),
}
@app.get("/health")
def health():
names = getattr(model, "names", {}) or {}
return {
"ok": True,
"model": _MODEL_PATH,
"classCount": len(names),
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
}

File diff suppressed because it is too large Load Diff

View File

@ -83,12 +83,12 @@ func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) {
func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) { func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return "", "", "", "", "", fmt.Errorf("empty id") return "", "", "", "", "", appErrorf("empty id")
} }
assetDir, err = ensureGeneratedDir(id) assetDir, err = ensureGeneratedDir(id)
if err != nil || strings.TrimSpace(assetDir) == "" { if err != nil || strings.TrimSpace(assetDir) == "" {
return "", "", "", "", "", fmt.Errorf("generated dir: %v", err) return "", "", "", "", "", appErrorf("generated dir: %v", err)
} }
thumbPath = filepath.Join(assetDir, "preview.jpg") thumbPath = filepath.Join(assetDir, "preview.jpg")
@ -103,6 +103,115 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
} }
func requiredAnalyzeGoals() []string {
return []string{"nsfw", "highlights"}
}
func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool {
// Der Name bleibt für Kompatibilität,
// aber semantisch heißt das jetzt:
// "Sind alle Analysen gespeichert?"
return hasAIAnalysisForAllOutputGoals(outPath, goals)
}
func ensureAnalyzeAllGoalsForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
return ensureAnalyzeAllGoalsForVideoCtxForce(ctx, videoPath, sourceURL, false)
}
func ensureAnalyzeAllGoalsForVideoCtxForce(
ctx context.Context,
videoPath string,
sourceURL string,
force bool,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
// Normaler Asset-/Postwork-Pfad:
// vorhandene Analyse behalten.
if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
actx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
durationSec := meta.durSec
if durationSec <= 0 {
durationSec, _ = durationSecondsForAnalyze(actx, videoPath)
}
if durationSec <= 0 {
return false, appErrorf("videolänge konnte nicht bestimmt werden")
}
// NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren.
nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(
actx,
videoPath,
"all",
)
if err != nil {
return false, err
}
nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw")
nsfwRating := computeNSFWRating(nsfwSegments, durationSec)
nsfwAI := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "video",
Hits: nsfwHits,
Segments: nsfwSegments,
Rating: nsfwRating,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil {
return false, err
}
highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights")
highlightRating := computeNSFWRating(highlightSegments, durationSec)
highlightAI := &aiAnalysisMeta{
Goal: "highlights",
Mode: "video",
Hits: highlightHits,
Segments: highlightSegments,
Rating: highlightRating,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, highlightAI); err != nil {
return false, err
}
return hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()), nil
}
func assetsTruthForVideo(videoPath string) finishedPhaseTruth { func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
@ -132,8 +241,8 @@ func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.TeaserReady = fileExistsNonEmpty(previewPath)
truth.SpritesReady = fileExistsNonEmpty(spritePath) truth.SpritesReady = fileExistsNonEmpty(spritePath)
// Analyse weiterhin inhaltlich prüfen // Analyse für Standard-Postwork: NSFW-Rating
truth.AnalyzeReady = hasAIResultsForOutput(videoPath) truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
return truth return truth
} }
@ -365,7 +474,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
if err := atomicWriteFile(thumbPath, img); err == nil { if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true out.ThumbGenerated = true
} else { } else {
fmt.Println("⚠️ thumb write:", err) appLogln("⚠️ thumb write:", err)
} }
} }
}() }()
@ -628,14 +737,14 @@ func ensureTeaserForVideoCtx(
nil, nil,
); err != nil { ); err != nil {
if isFFmpegInputInvalidError(err) { if isFFmpegInputInvalidError(err) {
return false, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err) return false, appErrorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err)
} }
return false, fmt.Errorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err) return false, appErrorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err)
} }
// Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren // Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren
if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 { if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 {
return false, fmt.Errorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath) return false, appErrorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath)
} }
var computedPreviewClips []previewClip var computedPreviewClips []previewClip
@ -781,7 +890,12 @@ func ensureAnalyzeForVideoCtx(
return false, nil return false, nil
} }
if hasAIResultsForOutput(videoPath) { goal = normalizeAIGoal(goal)
if goal == "" {
goal = "nsfw"
}
if hasAIAnalysisForOutputGoal(videoPath, goal) {
return false, nil return false, nil
} }
@ -795,55 +909,20 @@ func ensureAnalyzeForVideoCtx(
return false, nil return false, nil
} }
_, _, _, spritePath, metaPath, perr := assetPathsForID(id) _, _, _, _, _, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return false, perr return false, perr
} }
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) // Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr.
if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil {
if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 { return false, err
return false, nil
} }
goal = strings.ToLower(strings.TrimSpace(goal)) return hasAIAnalysisForOutputGoal(videoPath, goal), nil
if goal == "" {
goal = "nsfw"
}
actx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal)
if aerr != nil {
return false, nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if goal == "nsfw" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{
Goal: goal,
Mode: "sprite",
Hits: hits,
Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil {
return false, nil
}
return true, nil
} }
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string, goal string) error {
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
if videoPath == "" { if videoPath == "" {
return nil return nil
@ -859,26 +938,23 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil return nil
} }
// NUR noch langsame Background-Schritte: // Sprite weiterhin best-effort erzeugen.
// 1) Sprite
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) appLogln("⚠️ deferred sprite failed:", err)
} }
spriteTruth := assetsTruthForVideo(videoPath) // Analyse genau einmal für alle Standard-Ziele.
if !spriteTruth.SpritesReady { if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) return appErrorf("deferred analyze failed for %s: %w", videoPath, err)
} }
// 2) Analyse goal = normalizeAIGoal(goal)
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") if goal == "" {
if err != nil { goal = "highlights"
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
} }
analyzeTruth := assetsTruthForVideo(videoPath) if !hasAIAnalysisForOutputGoal(videoPath, goal) {
if !analyzeReady && !analyzeTruth.AnalyzeReady { return appErrorf("Analyse fehlt nach deferred analyze: %s", id)
return fmt.Errorf("Analyse fehlt nach deferred analyze: %s", id)
} }
return nil return nil
@ -896,13 +972,22 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
return false return false
} }
requiredGoals := requiredAnalyzeGoals()
truth := assetsTruthForVideo(outPath) truth := assetsTruthForVideo(outPath)
needMeta := !truth.MetaReady needMeta := !truth.MetaReady
needThumb := !truth.ThumbReady needThumb := !truth.ThumbReady
needTeaser := !truth.TeaserReady needTeaser := !truth.TeaserReady
needSprites := !truth.SpritesReady needSprites := !truth.SpritesReady
needAnalyze := !truth.AnalyzeReady
needAnalyze := false
for _, goal := range requiredGoals {
if !hasAIAnalysisForOutputGoal(outPath, goal) {
needAnalyze = true
break
}
}
// Nichts zu tun -> erfolgreich // Nichts zu tun -> erfolgreich
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
@ -923,7 +1008,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
} }
if needAnalyze { if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil)
} }
sortBucket, sortName := postWorkSortForVideoPath(outPath) sortBucket, sortName := postWorkSortForVideoPath(outPath)
@ -945,7 +1030,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
return true return true
} }
enqueueErr := fmt.Errorf("enqueue failed") enqueueErr := appErrorf("enqueue failed")
// queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen // queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen
if needMeta { if needMeta {
@ -961,7 +1046,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
} }
if needAnalyze { if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen konnten nicht eingeplant werden", enqueueErr)
} }
return false return false
@ -1056,7 +1141,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
metaHasSprite = true metaHasSprite = true
} }
metaHasAI := hasAIResultsForOutput(videoPath) metaHasAI := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
// Nur dann wirklich komplett "fertig", wenn auch AI vorhanden ist // Nur dann wirklich komplett "fertig", wenn auch AI vorhanden ist
if thumbBefore && previewBefore && spriteBefore && meta.ok && metaHasSprite && metaHasAI { if thumbBefore && previewBefore && spriteBefore && meta.ok && metaHasSprite && metaHasAI {
@ -1120,7 +1205,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
if err := atomicWriteFile(thumbPath, img); err == nil { if err := atomicWriteFile(thumbPath, img); err == nil {
out.ThumbGenerated = true out.ThumbGenerated = true
} else { } else {
fmt.Println("⚠️ thumb write:", err) appLogln("⚠️ thumb write:", err)
} }
} }
}() }()
@ -1296,7 +1381,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return out, nil return out, nil
} }
func hasAIResultsForOutput(outPath string) bool { func hasAIResultsForOutputGoal(outPath string, goal string) bool {
outPath = strings.TrimSpace(outPath) outPath = strings.TrimSpace(outPath)
if outPath == "" { if outPath == "" {
return false return false
@ -1312,7 +1397,9 @@ func hasAIResultsForOutput(outPath string) bool {
return false return false
} }
aiMap, ok := readVideoMetaAI(metaPath) goal = normalizeAIGoal(goal)
aiMap, ok := readVideoMetaAIForGoal(metaPath, goal)
if !ok || aiMap == nil { if !ok || aiMap == nil {
return false return false
} }
@ -1323,6 +1410,74 @@ func hasAIResultsForOutput(outPath string) bool {
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
} }
func hasAIAnalysisForOutputGoal(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
}
// Wichtig:
// Auch eine Analyse ohne Treffer ist fertig, solange sie gespeichert wurde.
if rawGoal, ok := aiMap["goal"].(string); ok && normalizeAIGoal(rawGoal) == goal {
return true
}
if rawMode, ok := aiMap["mode"].(string); ok && strings.TrimSpace(rawMode) != "" {
return true
}
if _, ok := aiMap["hits"]; ok {
return true
}
if _, ok := aiMap["segments"]; ok {
return true
}
if _, ok := aiMap["analyzedAtUnix"]; ok {
return true
}
return false
}
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return false
}
if len(goals) == 0 {
goals = requiredAnalyzeGoals()
}
for _, goal := range goals {
goal = normalizeAIGoal(goal)
if !hasAIAnalysisForOutputGoal(outPath, goal) {
return false
}
}
return true
}
type PrepareSplitResult struct { type PrepareSplitResult struct {
AssetsReady bool AssetsReady bool
AnalyzeReady bool AnalyzeReady bool
@ -1335,12 +1490,12 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
if videoPath == "" { if videoPath == "" {
return out, fmt.Errorf("empty videoPath") return out, appErrorf("empty videoPath")
} }
fi, err := os.Stat(videoPath) fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return out, fmt.Errorf("video datei nicht gefunden") return out, appErrorf("video datei nicht gefunden")
} }
// 1) Nur schnelle / primäre Assets sicherstellen: // 1) Nur schnelle / primäre Assets sicherstellen:
@ -1356,7 +1511,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
id := assetIDFromVideoPath(videoPath) id := assetIDFromVideoPath(videoPath)
if id == "" { if id == "" {
return out, fmt.Errorf("konnte asset id nicht ableiten") return out, appErrorf("konnte asset id nicht ableiten")
} }
_, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id) _, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id)
@ -1382,15 +1537,15 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
goal = strings.ToLower(strings.TrimSpace(goal)) goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" { if goal == "" {
goal = "nsfw" goal = "highlights"
} }
// 2) Für Split brauchen wir zusätzlich Sprite + AI. // 2) Für Split brauchen wir zusätzlich Sprite + AI.
// Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen. // 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); err != nil { if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL, goal); err != nil {
// best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten // best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten
fmt.Println("⚠️ prepareVideoForSplit deferred enrich:", err) appLogln("⚠️ prepareVideoForSplit deferred enrich:", err)
} }
spriteExists := func() bool { spriteExists := func() bool {
@ -1403,27 +1558,22 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
out.SpriteReady = spriteExists out.SpriteReady = spriteExists
// 3) AI-Segmente prüfen // 3) AI-Segmente prüfen
if hasAIResultsForOutput(videoPath) { if hasAIAnalysisForOutputGoal(videoPath, goal) {
out.AnalyzeReady = true out.AnalyzeReady = true
return out, nil return out, nil
} }
// 4) Falls trotz Deferred-Step noch kein Sprite da ist, können wir AI nicht ausführen // 4) Fallback: AI direkt berechnen
if !spriteExists {
return out, nil
}
// 5) Letzter Fallback: AI jetzt direkt berechnen
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal) hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal)
if aerr != nil { if aerr != nil {
return out, nil return out, nil
} }
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal)
var rating *aiRatingMeta var rating *aiRatingMeta
if goal == "nsfw" { if goal == "nsfw" || goal == "highlights" {
rating = computeNSFWRating(segments, durationSec) rating = computeNSFWRating(segments, durationSec)
} }
@ -1440,6 +1590,6 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, nil return out, nil
} }
out.AnalyzeReady = len(segments) > 0 out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal)
return out, nil return out, nil
} }

View File

@ -14,11 +14,20 @@ import (
) )
const ( const (
// Weniger Frames als vorher, aber Qualität pro Frame bleibt gut genug
// für Analyse + UI.
previewSpriteCols = 20 previewSpriteCols = 20
previewSpriteRows = 12 previewSpriteRows = 12
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
previewSpriteCellW = 320
previewSpriteCellH = 180 // Nicht kleiner machen, solange die Analyse dieselben Sprites nutzt.
// 320x180 ist für Positionen/Body/Object-Erkennung deutlich sicherer
// als 240x135.
previewSpriteCellW = 320
previewSpriteCellH = 180
// 4 ist kleiner als 3, aber noch gut genug für Analyse.
previewSpriteJpegQ = "4"
) )
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
@ -66,23 +75,23 @@ func generatePreviewSpriteJPG(
outPath = strings.TrimSpace(outPath) outPath = strings.TrimSpace(outPath)
if videoPath == "" { if videoPath == "" {
return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath") return appErrorf("generatePreviewSpriteJPG: empty videoPath")
} }
if outPath == "" { if outPath == "" {
return fmt.Errorf("generatePreviewSpriteJPG: empty outPath") return appErrorf("generatePreviewSpriteJPG: empty outPath")
} }
if cols <= 0 || rows <= 0 { if cols <= 0 || rows <= 0 {
return fmt.Errorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows) return appErrorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows)
} }
if stepSec <= 0 { if stepSec <= 0 {
return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec) return appErrorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec)
} }
if cellW <= 0 || cellH <= 0 { if cellW <= 0 || cellH <= 0 {
return fmt.Errorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH) return appErrorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH)
} }
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return fmt.Errorf("mkdir sprite dir: %w", err) return appErrorf("mkdir sprite dir: %w", err)
} }
ext := filepath.Ext(outPath) ext := filepath.Ext(outPath)
@ -111,7 +120,7 @@ func generatePreviewSpriteJPG(
// robustere Filterkette // robustere Filterkette
vf := fmt.Sprintf( vf := fmt.Sprintf(
"fps=fps=1/%f:start_time=0:round=up,"+ "fps=fps=1/%f:start_time=0:round=up,"+
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ "scale=%d:%d:force_original_aspect_ratio=decrease:flags=fast_bilinear,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
"setsar=1,"+ "setsar=1,"+
"tile=%dx%d:margin=0:padding=0", "tile=%dx%d:margin=0:padding=0",
@ -135,7 +144,7 @@ func generatePreviewSpriteJPG(
"-frames:v", "1", "-frames:v", "1",
"-update", "1", "-update", "1",
"-c:v", "mjpeg", "-c:v", "mjpeg",
"-q:v", "3", "-q:v", previewSpriteJpegQ,
tmpPath, tmpPath,
) )
@ -164,7 +173,7 @@ func generatePreviewSpriteJPG(
msgErr = "(kein stderr)" msgErr = "(kein stderr)"
} }
return fmt.Errorf( return appErrorf(
"ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s", "ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s",
videoPath, videoPath,
tmpPath, tmpPath,
@ -176,15 +185,15 @@ func generatePreviewSpriteJPG(
fi, err := os.Stat(tmpPath) fi, err := os.Stat(tmpPath)
if err != nil { if err != nil {
return fmt.Errorf("sprite temp stat failed: %w", err) return appErrorf("sprite temp stat failed: %w", err)
} }
if fi.IsDir() || fi.Size() <= 0 { if fi.IsDir() || fi.Size() <= 0 {
return fmt.Errorf("sprite temp file invalid/empty") return appErrorf("sprite temp file invalid/empty")
} }
_ = os.Remove(outPath) _ = os.Remove(outPath)
if err := os.Rename(tmpPath, outPath); err != nil { if err := os.Rename(tmpPath, outPath); err != nil {
return fmt.Errorf("sprite rename failed: %w", err) return appErrorf("sprite rename failed: %w", err)
} }
renameOK = true renameOK = true

View File

@ -6,7 +6,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -98,11 +97,11 @@ func (am *AuthManager) loadFromFileIfExists() error {
// ✅ Datei fehlt: bootstrap erzeugen + Passwort ausgeben // ✅ Datei fehlt: bootstrap erzeugen + Passwort ausgeben
return am.bootstrapAuthFile(abs) return am.bootstrapAuthFile(abs)
} }
return fmt.Errorf("read auth config: %w", err) return appErrorf("read auth config: %w", err)
} }
var c authConfig var c authConfig
if err := json.Unmarshal(b, &c); err != nil { if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("parse auth config: %w", err) return appErrorf("parse auth config: %w", err)
} }
am.confMu.Lock() am.confMu.Lock()
am.conf = c am.conf = c
@ -191,13 +190,13 @@ func (am *AuthManager) bootstrapAuthFile(abs string) error {
if passHash == "" { if passHash == "" {
p, err := generateRandomPassword() p, err := generateRandomPassword()
if err != nil { if err != nil {
return fmt.Errorf("generate password: %w", err) return appErrorf("generate password: %w", err)
} }
plain = p plain = p
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil { if err != nil {
return fmt.Errorf("bcrypt: %w", err) return appErrorf("bcrypt: %w", err)
} }
passHash = string(h) passHash = string(h)
} }
@ -216,31 +215,31 @@ func (am *AuthManager) bootstrapAuthFile(abs string) error {
// Datei schreiben // Datei schreiben
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return fmt.Errorf("mkdir auth dir: %w", err) return appErrorf("mkdir auth dir: %w", err)
} }
b, err := json.MarshalIndent(c, "", " ") b, err := json.MarshalIndent(c, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshal auth config: %w", err) return appErrorf("marshal auth config: %w", err)
} }
b = append(b, '\n') b = append(b, '\n')
if err := atomicWriteFileCompat(abs, b); err != nil { if err := atomicWriteFileCompat(abs, b); err != nil {
return fmt.Errorf("write auth config: %w", err) return appErrorf("write auth config: %w", err)
} }
// Passwort in Konsole anzeigen (nur wenn wir es generiert haben) // Passwort in Konsole anzeigen (nur wenn wir es generiert haben)
if plain != "" { if plain != "" {
fmt.Println("🔐 auth.json wurde neu erstellt:") appLogln("🔐 auth.json wurde neu erstellt:")
fmt.Println(" Datei: ", abs) appLogln(" Datei: ", abs)
fmt.Println(" Username:", user) appLogln(" Username:", user)
fmt.Println(" Passwort:", plain) appLogln(" Passwort:", plain)
fmt.Println(" Hinweis: Bitte sofort sichern/ändern das Passwort wird nur beim Erstellen angezeigt.") appLogln(" Hinweis: Bitte sofort sichern/ändern das Passwort wird nur beim Erstellen angezeigt.")
} else { } else {
fmt.Println("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):") appLogln("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):")
fmt.Println(" Datei: ", abs) appLogln(" Datei: ", abs)
fmt.Println(" Username:", user) appLogln(" Username:", user)
fmt.Println(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).") appLogln(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).")
} }
return nil return nil

View File

@ -298,7 +298,7 @@ func clearAllPendingAutoStartOnStartup() error {
func startChaturbateAutoStartWorker(store *ModelStore) { func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil { if store == nil {
if verboseLogs() { if verboseLogs() {
fmt.Println("⚠️ [autostart] model store is nil") appLogln("⚠️ [autostart] model store is nil")
} }
return return
} }
@ -359,9 +359,9 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if !fresh { if !fresh {
if verboseLogs() { if verboseLogs() {
if fetchedAt.IsZero() { if fetchedAt.IsZero() {
fmt.Println("⚠️ [autostart] chaturbate snapshot missing") appLogln("⚠️ [autostart] chaturbate snapshot missing")
} else { } else {
fmt.Println("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second)) appLogln("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second))
} }
} }
continue continue
@ -384,7 +384,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
if err != nil { if err != nil {
if verboseLogs() { if verboseLogs() {
fmt.Println("⚠️ [autostart] load manual chaturbate pending failed:", err) appLogln("⚠️ [autostart] load manual chaturbate pending failed:", err)
} }
continue continue
} }
@ -394,7 +394,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
if err != nil { if err != nil {
if verboseLogs() { if verboseLogs() {
fmt.Println("⚠️ [autostart] load resume chaturbate pending failed:", err) appLogln("⚠️ [autostart] load resume chaturbate pending failed:", err)
} }
continue continue
} }
@ -643,7 +643,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
resumePendingThisScan[user] = true resumePendingThisScan[user] = true
if verboseLogs() { if verboseLogs() {
fmt.Println("⏸️ [autostart] stopped because model is no longer public:", user, show) appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show)
} }
stopJobsInternal([]*RecordJob{runningJob}) stopJobsInternal([]*RecordJob{runningJob})
@ -953,16 +953,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}) })
if err != nil { if err != nil {
if verboseLogs() { if verboseLogs() {
fmt.Println("❌ [autostart] start failed:", it.url, err) appLogln("❌ [autostart] start failed:", it.url, err)
} }
continue continue
} }
if verboseLogs() { if verboseLogs() {
if it.source == "resume" { if it.source == "resume" {
fmt.Println("▶️ [autostart] resumed:", it.url) appLogln("▶️ [autostart] resumed:", it.url)
} else { } else {
fmt.Println("▶️ [autostart] started:", it.url) appLogln("▶️ [autostart] started:", it.url)
} }
} }
@ -1016,13 +1016,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}) })
if err != nil || job == nil { if err != nil || job == nil {
if verboseLogs() { if verboseLogs() {
fmt.Println("❌ [autostart] blind start failed:", it.url, err) appLogln("❌ [autostart] blind start failed:", it.url, err)
} }
continue continue
} }
if verboseLogs() { if verboseLogs() {
fmt.Println("▶️ [autostart] blind try started:", it.url) appLogln("▶️ [autostart] blind try started:", it.url)
} }
if it.source == "manual" { if it.source == "manual" {

View File

@ -183,7 +183,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) return nil, appErrorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
} }
dec := json.NewDecoder(resp.Body) dec := json.NewDecoder(resp.Body)
@ -194,7 +194,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
return nil, err return nil, err
} }
if d, ok := tok.(json.Delim); !ok || d != '[' { if d, ok := tok.(json.Delim); !ok || d != '[' {
return nil, fmt.Errorf("chaturbate online rooms: expected JSON array") return nil, appErrorf("chaturbate online rooms: expected JSON array")
} }
rooms := make([]ChaturbateRoom, 0, 4096) rooms := make([]ChaturbateRoom, 0, 4096)
@ -365,7 +365,7 @@ func selectBestRoomImageURL(rm ChaturbateRoom) string {
func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, data []byte, err error) { func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, data []byte, err error) {
u := strings.TrimSpace(rawURL) u := strings.TrimSpace(rawURL)
if u == "" { if u == "" {
return "", nil, fmt.Errorf("empty image url") return "", nil, appErrorf("empty image url")
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
@ -383,7 +383,7 @@ func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, da
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) return "", nil, appErrorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
} }
// Sicherheitslimit (Profilbilder sind klein) // Sicherheitslimit (Profilbilder sind klein)
@ -393,10 +393,10 @@ func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, da
return "", nil, err return "", nil, err
} }
if len(b) == 0 { if len(b) == 0 {
return "", nil, fmt.Errorf("empty image body") return "", nil, appErrorf("empty image body")
} }
if len(b) > maxImageBytes { if len(b) > maxImageBytes {
return "", nil, fmt.Errorf("image too large") return "", nil, appErrorf("image too large")
} }
ct := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Type"))) ct := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Type")))
@ -598,7 +598,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
// wiederholt loggen, nicht nur bei geändertem Fehlertext // wiederholt loggen, nicht nur bei geändertem Fehlertext
if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second { if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second {
if prevFetchedAt.IsZero() { if prevFetchedAt.IsZero() {
fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg) appLogln("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg)
} else { } else {
fmt.Printf( fmt.Printf(
"⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n", "⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n",
@ -634,7 +634,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
} }
if lastLoggedErr != "" { if lastLoggedErr != "" {
fmt.Println("✅ [chaturbate] online rooms fetch recovered") appLogln("✅ [chaturbate] online rooms fetch recovered")
lastLoggedErr = "" lastLoggedErr = ""
} }

View File

@ -6,7 +6,6 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@ -137,7 +136,7 @@ func decryptBase64ToBytes(blob string) ([]byte, error) {
} }
ns := gcm.NonceSize() ns := gcm.NonceSize()
if len(buf) < ns { if len(buf) < ns {
return nil, fmt.Errorf("encrypted cookies: invalid length") return nil, appErrorf("encrypted cookies: invalid length")
} }
nonce := buf[:ns] nonce := buf[:ns]
ciphertext := buf[ns:] ciphertext := buf[ns:]

View File

@ -460,7 +460,7 @@ func startDiskSpaceGuard() {
func ensureDiskGuardAllowsStart() error { func ensureDiskGuardAllowsStart() error {
if atomic.LoadInt32(&diskEmergency) == 1 { if atomic.LoadInt32(&diskEmergency) == 1 {
return fmt.Errorf("Speicherplatz-Notbremse aktiv: neuer Download wird blockiert") return appErrorf("Speicherplatz-Notbremse aktiv: neuer Download wird blockiert")
} }
s := getSettings() s := getSettings()
@ -480,7 +480,7 @@ func ensureDiskGuardAllowsStart() error {
stopped := stopAllStoppableJobs() stopped := stopAllStoppableJobs()
return fmt.Errorf( return appErrorf(
"Speicherplatz-Notbremse aktiv: frei=%s, benötigt=%s, pause=%dGB, resume=%dGB, reserve=%s, gestoppt=%d, pfad=%s", "Speicherplatz-Notbremse aktiv: frei=%s, benötigt=%s, pause=%dGB, resume=%dGB, reserve=%s, gestoppt=%d, pfad=%s",
formatBytesSI(u64ToI64(free)), formatBytesSI(u64ToI64(free)),
formatBytesSI(u64ToI64(pauseNeed)), formatBytesSI(u64ToI64(pauseNeed)),

View File

@ -1,3 +1,5 @@
// backend\embed.go
package main package main
import ( import (
@ -6,7 +8,7 @@ import (
"path/filepath" "path/filepath"
) )
//go:embed ml/*.py ml/*.json //go:embed ml/*.py ml/*.json ai_server.py
var embeddedMLFiles embed.FS var embeddedMLFiles embed.FS
func trainingEmbeddedMLDir() (string, error) { func trainingEmbeddedMLDir() (string, error) {
@ -40,3 +42,23 @@ func trainingEmbeddedMLDir() (string, error) {
return dir, nil return dir, nil
} }
func embeddedAIServerDir() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
b, err := embeddedMLFiles.ReadFile("ai_server.py")
if err != nil {
return "", err
}
dstPath := filepath.Join(dir, "ai_server.py")
if err := os.WriteFile(dstPath, b, 0644); err != nil {
return "", err
}
return dir, nil
}

View File

@ -4,7 +4,6 @@ package main
import ( import (
"embed" "embed"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"path" "path"
@ -20,13 +19,13 @@ var embeddedFrontend embed.FS
func makeFrontendHandler() (http.Handler, bool) { func makeFrontendHandler() (http.Handler, bool) {
distFS, err := fs.Sub(embeddedFrontend, "web/dist") distFS, err := fs.Sub(embeddedFrontend, "web/dist")
if err != nil { if err != nil {
fmt.Println("⚠️ Frontend dist nicht im Binary gefunden API läuft trotzdem:", err) appLogln("⚠️ Frontend dist nicht im Binary gefunden API läuft trotzdem:", err)
return nil, false return nil, false
} }
// Prüfen, ob index.html vorhanden ist // Prüfen, ob index.html vorhanden ist
if _, err := fs.Stat(distFS, "index.html"); err != nil { if _, err := fs.Stat(distFS, "index.html"); err != nil {
fmt.Println("⚠️ Frontend index.html nicht im Binary gefunden API läuft trotzdem:", err) appLogln("⚠️ Frontend index.html nicht im Binary gefunden API läuft trotzdem:", err)
return nil, false return nil, false
} }

View File

@ -67,7 +67,7 @@ func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) { func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
u := strings.TrimSpace(username) u := strings.TrimSpace(username)
if u == "" { if u == "" {
return "", fmt.Errorf("empty username") return "", appErrorf("empty username")
} }
if strings.TrimSpace(userAgent) == "" { if strings.TrimSpace(userAgent) == "" {
@ -89,7 +89,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
master = strings.TrimSpace(master) master = strings.TrimSpace(master)
if master == "" { if master == "" {
return "", fmt.Errorf("empty parsed hls url") return "", appErrorf("empty parsed hls url")
} }
return master, nil return master, nil
@ -161,7 +161,7 @@ func stopPreview(job *RecordJob) {
func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
if job == nil { if job == nil {
return fmt.Errorf("job nil") return appErrorf("job nil")
} }
jobsMu.RLock() jobsMu.RLock()
@ -189,7 +189,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
username := strings.TrimSpace(extractUsername(sourceURL)) username := strings.TrimSpace(extractUsername(sourceURL))
if username == "" { if username == "" {
return fmt.Errorf("username not found") return appErrorf("username not found")
} }
// ✅ wichtig: doppelten Refresh verhindern // ✅ wichtig: doppelten Refresh verhindern
@ -206,7 +206,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
} }
newHls = strings.TrimSpace(newHls) newHls = strings.TrimSpace(newHls)
if newHls == "" { if newHls == "" {
return fmt.Errorf("empty hls") return appErrorf("empty hls")
} }
var oldHls string var oldHls string
@ -501,7 +501,7 @@ func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) {
// It also starts your existing live-thumb loop: startLiveThumbJPGLoop(ctx, job). // It also starts your existing live-thumb loop: startLiveThumbJPGLoop(ctx, job).
func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error { func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error {
if strings.TrimSpace(ffmpegPath) == "" { if strings.TrimSpace(ffmpegPath) == "" {
return fmt.Errorf("kein ffmpeg gefunden setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") return appErrorf("kein ffmpeg gefunden setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend")
} }
if err := os.MkdirAll(previewDir, 0o755); err != nil { if err := os.MkdirAll(previewDir, 0o755); err != nil {
return err return err

266
backend/log.go Normal file
View File

@ -0,0 +1,266 @@
// backend\log.go
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
var (
appLogMu sync.Mutex
appLogFile *os.File
)
func appLogPath() string {
exePath, _ := os.Executable()
exeDir := ""
if exePath != "" {
exeDir = filepath.Dir(exePath)
}
if exeDir == "" {
exeDir, _ = os.Getwd()
}
return filepath.Join(exeDir, "recorder.log")
}
func openAppLogFile() (*os.File, error) {
path := appLogPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
}
func initAppLog() {
appLogMu.Lock()
defer appLogMu.Unlock()
if appLogFile != nil {
return
}
f, err := openAppLogFile()
if err != nil {
// Wichtig: hier NICHT appLogln verwenden, weil appLogMu schon gelockt ist.
fmt.Println("⚠️ recorder log konnte nicht geöffnet werden:", err)
return
}
appLogFile = f
_, _ = fmt.Fprintf(
appLogFile,
"\n\n--- Recorder Start %s ---\nlog=%s\n",
time.Now().Format(time.RFC3339),
appLogPath(),
)
_ = appLogFile.Sync()
}
func closeAppLog() {
appLogMu.Lock()
defer appLogMu.Unlock()
if appLogFile == nil {
return
}
_, _ = fmt.Fprintf(
appLogFile,
"--- Recorder Stop %s ---\n",
time.Now().Format(time.RFC3339),
)
_ = appLogFile.Sync()
_ = appLogFile.Close()
appLogFile = nil
}
func appLogWriter() io.Writer {
initAppLog()
appLogMu.Lock()
defer appLogMu.Unlock()
if appLogFile != nil {
return appLogFile
}
return os.Stderr
}
func appLogWriteLine(line string) {
line = strings.TrimRight(line, "\r\n")
ts := time.Now().Format("2006-01-02 15:04:05")
out := fmt.Sprintf("[%s] %s\n", ts, line)
appLogMu.Lock()
defer appLogMu.Unlock()
fmt.Print(out)
if appLogFile != nil {
_, _ = appLogFile.WriteString(out)
_ = appLogFile.Sync()
}
}
func appLogln(args ...any) {
appLogWriteLine(fmt.Sprintln(args...))
}
func appLogf(format string, args ...any) {
appLogWriteLine(fmt.Sprintf(format, args...))
}
func appErrorf(format string, args ...any) error {
err := fmt.Errorf(format, args...)
appLogln("❌", err)
return err
}
// Nur zentraler Ersatz für fmt.Sprintf.
// Loggt absichtlich NICHT, weil Sprintf oft nur UI-/Command-Strings baut.
func appSprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
// Zentraler Ersatz für fmt.Sprint.
func appSprint(args ...any) string {
return fmt.Sprint(args...)
}
func appLogHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
path := appLogPath()
maxBytes := int64(64 * 1024)
switch strings.TrimSpace(r.URL.Query().Get("maxBytes")) {
case "16384":
maxBytes = 16 * 1024
case "32768":
maxBytes = 32 * 1024
case "131072":
maxBytes = 128 * 1024
case "262144":
maxBytes = 256 * 1024
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"path": path,
"exists": false,
"log": "",
"error": "Logdatei existiert noch nicht.",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"path": path,
"exists": false,
"log": "",
"error": err.Error(),
})
return
}
if info.IsDir() {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"path": path,
"exists": true,
"log": "",
"error": "Logpfad ist ein Ordner.",
})
return
}
f, err := os.Open(path)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"path": path,
"exists": true,
"log": "",
"error": err.Error(),
})
return
}
defer f.Close()
size := info.Size()
offset := int64(0)
if size > maxBytes {
offset = size - maxBytes
}
buf := make([]byte, size-offset)
if len(buf) > 0 {
if _, err := f.ReadAt(buf, offset); err != nil && err != io.EOF {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"path": path,
"exists": true,
"log": "",
"error": err.Error(),
})
return
}
}
logText := string(buf)
if offset > 0 {
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"path": path,
"exists": true,
"sizeBytes": size,
"log": logText,
})
}
func clearAppLog() {
appLogMu.Lock()
defer appLogMu.Unlock()
if appLogFile != nil {
_ = appLogFile.Close()
appLogFile = nil
}
path := appLogPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
fmt.Println("⚠️ recorder log dir konnte nicht erstellt werden:", err)
return
}
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
fmt.Println("⚠️ recorder log konnte nicht geleert werden:", err)
}
}

View File

@ -249,7 +249,7 @@ func parseFFRate(s string) float64 {
func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps float64, err error) { func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps float64, err error) {
filePath = strings.TrimSpace(filePath) filePath = strings.TrimSpace(filePath)
if filePath == "" { if filePath == "" {
return 0, 0, 0, fmt.Errorf("empty path") return 0, 0, 0, appErrorf("empty path")
} }
cmd := exec.CommandContext(ctx, ffprobePath, cmd := exec.CommandContext(ctx, ffprobePath,
@ -274,7 +274,7 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl
return 0, 0, 0, err return 0, 0, 0, err
} }
if len(info.Streams) == 0 { if len(info.Streams) == 0 {
return 0, 0, 0, fmt.Errorf("no video stream") return 0, 0, 0, appErrorf("no video stream")
} }
s := info.Streams[0] s := info.Streams[0]
@ -512,14 +512,14 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
m := re.FindStringSubmatch(text) m := re.FindStringSubmatch(text)
if len(m) != 4 { if len(m) != 4 {
return 0, fmt.Errorf("duration not found") return 0, appErrorf("duration not found")
} }
hh, _ := strconv.ParseFloat(m[1], 64) hh, _ := strconv.ParseFloat(m[1], 64)
mm, _ := strconv.ParseFloat(m[2], 64) mm, _ := strconv.ParseFloat(m[2], 64)
ss, _ := strconv.ParseFloat(m[3], 64) ss, _ := strconv.ParseFloat(m[3], 64)
sec := hh*3600 + mm*60 + ss sec := hh*3600 + mm*60 + ss
if sec <= 0 { if sec <= 0 {
return 0, fmt.Errorf("invalid duration") return 0, appErrorf("invalid duration")
} }
durCache.mu.Lock() durCache.mu.Lock()
@ -816,7 +816,7 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url) return "", appErrorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
} }
return body, nil return body, nil
@ -839,7 +839,7 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String()) return appErrorf("ffmpeg remux failed: %v (%s)", err, stderr.String())
} }
return nil return nil
} }
@ -996,7 +996,7 @@ func remuxTSToMP4WithProgress(
} }
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String())) return appErrorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
} }
return nil return nil
} }
@ -1094,18 +1094,18 @@ func ensureFastStartMP4(path string) error {
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String())) return appErrorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
} }
// atomar austauschen // atomar austauschen
if err := os.Rename(path, bak); err != nil { if err := os.Rename(path, bak); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("rename original to bak failed: %w", err) return appErrorf("rename original to bak failed: %w", err)
} }
if err := os.Rename(tmp, path); err != nil { if err := os.Rename(tmp, path); err != nil {
_ = os.Rename(bak, path) _ = os.Rename(bak, path)
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("rename tmp to original failed: %w", err) return appErrorf("rename tmp to original failed: %w", err)
} }
_ = os.Remove(bak) _ = os.Remove(bak)
return nil return nil
@ -1133,19 +1133,19 @@ func generatedMetaRoot() (string, error) {
func generatedThumbJPGFile(assetID string) (string, error) { func generatedThumbJPGFile(assetID string) (string, error) {
assetID = stripHotPrefix(strings.TrimSpace(assetID)) assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" { if assetID == "" {
return "", fmt.Errorf("empty assetID") return "", appErrorf("empty assetID")
} }
// gleiche Normalisierung wie bei Ordnernamen // gleiche Normalisierung wie bei Ordnernamen
var err error var err error
assetID, err = sanitizeID(assetID) assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" { if err != nil || assetID == "" {
return "", fmt.Errorf("invalid assetID: %w", err) return "", appErrorf("invalid assetID: %w", err)
} }
dir, err := ensureGeneratedDir(assetID) dir, err := ensureGeneratedDir(assetID)
if err != nil || strings.TrimSpace(dir) == "" { if err != nil || strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("ensureGeneratedDir: %w", err) return "", appErrorf("ensureGeneratedDir: %w", err)
} }
return filepath.Join(dir, "preview.jpg"), nil return filepath.Join(dir, "preview.jpg"), nil
@ -1204,7 +1204,7 @@ func uniqueDestPath(dstDir, file string) (string, error) {
return cand, nil return cand, nil
} }
} }
return "", fmt.Errorf("too many duplicates for %s", file) return "", appErrorf("too many duplicates for %s", file)
} else if err != nil && !os.IsNotExist(err) { } else if err != nil && !os.IsNotExist(err) {
return "", err return "", err
} }
@ -1300,7 +1300,7 @@ func fixKeepRootFilesIntoModelSubdirs() {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return return
} }
fmt.Println("⚠️ keep scan failed:", err) appLogln("⚠️ keep scan failed:", err)
return return
} }
@ -1341,7 +1341,7 @@ func fixKeepRootFilesIntoModelSubdirs() {
dstDir := filepath.Join(keepRoot, modelKey) dstDir := filepath.Join(keepRoot, modelKey)
if err := os.MkdirAll(dstDir, 0o755); err != nil { if err := os.MkdirAll(dstDir, 0o755); err != nil {
fmt.Println("⚠️ keep mkdir failed:", err) appLogln("⚠️ keep mkdir failed:", err)
skipped++ skipped++
continue continue
} }
@ -1364,19 +1364,19 @@ func fixKeepRootFilesIntoModelSubdirs() {
} }
} }
if !found { if !found {
fmt.Println("⚠️ keep fix: too many duplicates, skip:", name) appLogln("⚠️ keep fix: too many duplicates, skip:", name)
skipped++ skipped++
continue continue
} }
} else if err != nil && !os.IsNotExist(err) { } else if err != nil && !os.IsNotExist(err) {
fmt.Println("⚠️ keep stat dst failed:", err) appLogln("⚠️ keep stat dst failed:", err)
skipped++ skipped++
continue continue
} }
// Verschieben (Windows-lock robust) // Verschieben (Windows-lock robust)
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetry(src, dst); err != nil {
fmt.Println("⚠️ keep fix rename failed:", err) appLogln("⚠️ keep fix rename failed:", err)
skipped++ skipped++
continue continue
} }
@ -1396,7 +1396,7 @@ func findFinishedFileByID(id string) (string, error) {
base := stripHotPrefix(strings.TrimSpace(id)) base := stripHotPrefix(strings.TrimSpace(id))
if base == "" { if base == "" {
return "", fmt.Errorf("not found") return "", appErrorf("not found")
} }
names := []string{ names := []string{
@ -1419,7 +1419,7 @@ func findFinishedFileByID(id string) (string, error) {
} }
} }
return "", fmt.Errorf("not found") return "", appErrorf("not found")
} }
const maxInt64 = int64(^uint64(0) >> 1) const maxInt64 = int64(^uint64(0) >> 1)
@ -1486,22 +1486,22 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie) req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie)
if err != nil { if err != nil {
return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) return nil, appErrorf("Fehler beim Erstellen der Playlist-Request: %w", err)
} }
resp, err := hc.client.Do(req) resp, err := hc.client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err) return nil, appErrorf("Fehler beim Laden der Playlist: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource) return nil, appErrorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource)
} }
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("master playlist decode failed: %w", err) return nil, appErrorf("master playlist decode failed: %w", err)
} }
if listType != m3u8.MASTER { if listType != m3u8.MASTER {
return nil, errors.New("keine gültige Master-Playlist") return nil, errors.New("keine gültige Master-Playlist")
@ -1514,7 +1514,7 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
baseURL, err := url.Parse(hlsSource) baseURL, err := url.Parse(hlsSource)
if err != nil { if err != nil {
return nil, fmt.Errorf("master url parse failed: %w", err) return nil, appErrorf("master url parse failed: %w", err)
} }
var bestVariant *m3u8.Variant var bestVariant *m3u8.Variant
@ -1587,14 +1587,14 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI)) variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI))
if err != nil { if err != nil {
return nil, fmt.Errorf("variant uri parse failed: %w", err) return nil, appErrorf("variant uri parse failed: %w", err)
} }
playlistURL := baseURL.ResolveReference(variantRef).String() playlistURL := baseURL.ResolveReference(variantRef).String()
playlistParsed, err := url.Parse(playlistURL) playlistParsed, err := url.Parse(playlistURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("playlist url parse failed: %w", err) return nil, appErrorf("playlist url parse failed: %w", err)
} }
rootParsed := *playlistParsed rootParsed := *playlistParsed
@ -1630,7 +1630,7 @@ func fileExists(path string) bool {
func validateVideoDecodesNearEnd(ctx context.Context, path string) error { func validateVideoDecodesNearEnd(ctx context.Context, path string) error {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" { if path == "" {
return fmt.Errorf("empty path") return appErrorf("empty path")
} }
dur, err := durationSecondsCached(ctx, path) dur, err := durationSecondsCached(ctx, path)
@ -1645,10 +1645,10 @@ func validateVideoDecodesNearEnd(ctx context.Context, path string) error {
img, err := extractFrameAtTimeJPG(path, t) img, err := extractFrameAtTimeJPG(path, t)
if err != nil { if err != nil {
return fmt.Errorf("decode near end failed at %.3fs: %w", t, err) return appErrorf("decode near end failed at %.3fs: %w", t, err)
} }
if len(img) == 0 { if len(img) == 0 {
return fmt.Errorf("decode near end returned empty frame at %.3fs", t) return appErrorf("decode near end returned empty frame at %.3fs", t)
} }
return nil return nil
@ -1715,7 +1715,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String())) return appErrorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
} }
if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil { if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil {
@ -1727,7 +1727,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
if err := os.Rename(tmp, mp4Path); err != nil { if err := os.Rename(tmp, mp4Path); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("rename reencode tmp -> target failed: %w", err) return appErrorf("rename reencode tmp -> target failed: %w", err)
} }
return nil return nil

View File

@ -61,7 +61,9 @@ type previewMeta struct {
} }
type analysisMeta struct { type analysisMeta struct {
AI *aiAnalysisMeta `json:"ai,omitempty"` AI *aiAnalysisMeta `json:"ai,omitempty"`
NSFW *aiAnalysisMeta `json:"nsfw,omitempty"`
Highlights *aiAnalysisMeta `json:"highlights,omitempty"`
} }
type postworkHistoryEntry struct { type postworkHistoryEntry struct {
@ -141,7 +143,7 @@ func metaJSONPathForAssetID(assetID string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated/meta root leer") return "", appErrorf("generated/meta root leer")
} }
return filepath.Join(root, assetID, "meta.json"), nil return filepath.Join(root, assetID, "meta.json"), nil
} }
@ -393,6 +395,39 @@ func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
return false 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
}
}
return nil
}
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
m, ok := readVideoMetaIfValid(metaPath, fi) m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil { if !ok || m == nil {
@ -626,12 +661,12 @@ func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, source
func generatedMetaFile(assetID string) (string, error) { func generatedMetaFile(assetID string) (string, error) {
assetID = stripHotPrefix(strings.TrimSpace(assetID)) assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" { if assetID == "" {
return "", fmt.Errorf("empty assetID") return "", appErrorf("empty assetID")
} }
id, err := sanitizeID(assetID) id, err := sanitizeID(assetID)
if err != nil || id == "" { if err != nil || id == "" {
return "", fmt.Errorf("invalid assetID: %w", err) return "", appErrorf("invalid assetID: %w", err)
} }
return metaJSONPathForAssetID(id) return metaJSONPathForAssetID(id)
@ -647,7 +682,7 @@ func generatedDirForID(id string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated meta root ist leer") return "", appErrorf("generated meta root ist leer")
} }
return filepath.Join(root, id), nil return filepath.Join(root, id), nil
} }
@ -721,7 +756,7 @@ func ensureGeneratedDirs() error {
return err return err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return fmt.Errorf("generated meta root ist leer") return appErrorf("generated meta root ist leer")
} }
return os.MkdirAll(root, 0o755) return os.MkdirAll(root, 0o755)
} }
@ -729,10 +764,10 @@ func ensureGeneratedDirs() error {
func sanitizeID(id string) (string, error) { func sanitizeID(id string) (string, error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return "", fmt.Errorf("id fehlt") return "", appErrorf("id fehlt")
} }
if strings.ContainsAny(id, `/\`) { if strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("ungültige id") return "", appErrorf("ungültige id")
} }
return id, nil return id, nil
} }
@ -773,18 +808,13 @@ func writeVideoMetaAI(
Resolution: formatResolution(w, h), Resolution: formatResolution(w, h),
}, },
}, },
Analysis: analysisMeta{
AI: ai,
},
} }
if existing != nil { if existing != nil {
m.Preview = existing.Preview m.Preview = existing.Preview
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork m.Postwork = existing.Postwork
m.Validation = existing.Validation
if !hasAIAnalysisMeta(ai) && hasAIAnalysisMeta(existing.Analysis.AI) {
m.Analysis = existing.Analysis
}
if m.Media.Video.Width <= 0 { if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width m.Media.Video.Width = existing.Media.Video.Width
@ -806,6 +836,31 @@ func writeVideoMetaAI(
} }
} }
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, "", " ") buf, err := json.MarshalIndent(m, "", " ")
if err != nil { if err != nil {
return err return err
@ -827,23 +882,23 @@ func writeVideoAIForFile(
fi, err := os.Stat(fullPath) fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return fmt.Errorf("datei nicht gefunden") return appErrorf("datei nicht gefunden")
} }
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL) m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL)
if !ok || m == nil { if !ok || m == nil {
return fmt.Errorf("meta konnte nicht erzeugt werden") return appErrorf("meta konnte nicht erzeugt werden")
} }
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem)) assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" { if assetID == "" {
return fmt.Errorf("asset id fehlt") return appErrorf("asset id fehlt")
} }
assetID, err = sanitizeID(assetID) assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" { if err != nil || assetID == "" {
return fmt.Errorf("asset id ungültig: %w", err) return appErrorf("asset id ungültig: %w", err)
} }
metaPath, err := metaJSONPathForAssetID(assetID) metaPath, err := metaJSONPathForAssetID(assetID)
@ -851,6 +906,10 @@ func writeVideoAIForFile(
return err return err
} }
if strings.TrimSpace(sourceURL) == "" {
sourceURL = m.File.SourceURL
}
return writeVideoMetaAI( return writeVideoMetaAI(
metaPath, metaPath,
fi, fi,
@ -951,7 +1010,7 @@ func writeVideoMetaPostworkEntry(
return atomicWriteFile(metaPath, buf) return atomicWriteFile(metaPath, buf)
} }
func readVideoMetaAI(metaPath string) (map[string]any, bool) { func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) {
b, err := os.ReadFile(metaPath) b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 { if err != nil || len(b) == 0 {
return nil, false return nil, false
@ -969,12 +1028,29 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) {
return nil, false return nil, false
} }
ai, ok := rawAnalysis["ai"].(map[string]any) goal = normalizeAIGoal(goal)
if !ok || ai == nil {
return nil, false 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
} }
return ai, true return nil, false
} }
func writePostworkEntryForFile( func writePostworkEntryForFile(
@ -990,18 +1066,18 @@ func writePostworkEntryForFile(
) error { ) error {
fullPath = strings.TrimSpace(fullPath) fullPath = strings.TrimSpace(fullPath)
if fullPath == "" { if fullPath == "" {
return fmt.Errorf("fullPath fehlt") return appErrorf("fullPath fehlt")
} }
fi, err := os.Stat(fullPath) fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() { if err != nil || fi == nil || fi.IsDir() {
return fmt.Errorf("datei nicht gefunden") return appErrorf("datei nicht gefunden")
} }
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem)) assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" { if assetID == "" {
return fmt.Errorf("asset id fehlt") return appErrorf("asset id fehlt")
} }
assetID, err = sanitizeID(assetID) assetID, err = sanitizeID(assetID)
@ -1016,7 +1092,7 @@ func writePostworkEntryForFile(
// sorgt dafür, dass meta.json grundsätzlich existiert // sorgt dafür, dass meta.json grundsätzlich existiert
if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok { if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok {
return fmt.Errorf("meta konnte nicht erzeugt werden") return appErrorf("meta konnte nicht erzeugt werden")
} }
return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{ return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{

View File

@ -10,10 +10,10 @@
"cowgirl", "cowgirl",
"reverse_cowgirl", "reverse_cowgirl",
"cunnilingus", "cunnilingus",
"prone_bone",
"standing", "standing",
"standing_doggy", "standing_doggy",
"spooning", "spooning",
"sideways",
"sitting", "sitting",
"facesitting", "facesitting",
"handjob", "handjob",
@ -46,8 +46,6 @@
"bikini", "bikini",
"bra", "bra",
"dress", "dress",
"fishnet",
"harness",
"heels", "heels",
"hotpants", "hotpants",
"lingerie", "lingerie",

View File

@ -6,7 +6,6 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -1035,10 +1034,10 @@ ORDER BY updated_at DESC;
rows, err := s.db.Query(q1) rows, err := s.db.Query(q1)
if err != nil { if err != nil {
// ✅ genau dein Fall: "Spalte existiert nicht" -> fallback // ✅ genau dein Fall: "Spalte existiert nicht" -> fallback
fmt.Println("models List query err (q1):", err) appLogln("models List query err (q1):", err)
rows, err = s.db.Query(q2) rows, err = s.db.Query(q2)
if err != nil { if err != nil {
fmt.Println("models List query err (q2):", err) appLogln("models List query err (q2):", err)
return []StoredModel{} return []StoredModel{}
} }
} }

View File

@ -137,7 +137,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc") manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc")
pendingAutoStartMu.Unlock() pendingAutoStartMu.Unlock()
if err != nil { if err != nil {
fmt.Println("⚠️ [mfc autostart] load manual pending failed:", err) appLogln("⚠️ [mfc autostart] load manual pending failed:", err)
continue continue
} }

View File

@ -4,7 +4,6 @@ package main
import ( import (
"embed" "embed"
"fmt"
"io" "io"
"io/fs" "io/fs"
"os" "os"
@ -27,7 +26,7 @@ func nsfwRuntimeBaseDir() (string, error) {
base = strings.TrimSpace(os.Getenv("TEMP")) base = strings.TrimSpace(os.Getenv("TEMP"))
} }
if base == "" { if base == "" {
return "", fmt.Errorf("LOCALAPPDATA/TEMP nicht gefunden") return "", appErrorf("LOCALAPPDATA/TEMP nicht gefunden")
} }
return filepath.Join(base, appName, nsfwRuntimeVersion), nil return filepath.Join(base, appName, nsfwRuntimeVersion), nil
} }
@ -51,22 +50,22 @@ func ensureNSFWAssetsExtracted() (string, error) {
} }
if _, err := fs.Stat(embeddedNSFWAssets, "assets/nsfw"); err != nil { if _, err := fs.Stat(embeddedNSFWAssets, "assets/nsfw"); err != nil {
return "", fmt.Errorf("embedded assets/nsfw nicht gefunden: %w", err) return "", appErrorf("embedded assets/nsfw nicht gefunden: %w", err)
} }
if err := os.RemoveAll(root); err != nil { if err := os.RemoveAll(root); err != nil {
return "", fmt.Errorf("runtime-ordner konnte nicht bereinigt werden: %w", err) return "", appErrorf("runtime-ordner konnte nicht bereinigt werden: %w", err)
} }
if err := os.MkdirAll(root, 0o755); err != nil { if err := os.MkdirAll(root, 0o755); err != nil {
return "", fmt.Errorf("runtime-ordner konnte nicht erstellt werden: %w", err) return "", appErrorf("runtime-ordner konnte nicht erstellt werden: %w", err)
} }
if err := extractEmbeddedDir("assets/nsfw", root); err != nil { if err := extractEmbeddedDir("assets/nsfw", root); err != nil {
return "", fmt.Errorf("nsfw-assets extrahieren fehlgeschlagen: %w", err) return "", appErrorf("nsfw-assets extrahieren fehlgeschlagen: %w", err)
} }
if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil { if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil {
return "", fmt.Errorf("markerdatei konnte nicht geschrieben werden: %w", err) return "", appErrorf("markerdatei konnte nicht geschrieben werden: %w", err)
} }
return root, nil return root, nil

View File

@ -5,7 +5,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"fmt"
"image" "image"
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
@ -94,22 +93,22 @@ func initNSFWDetector() error {
case "darwin": case "darwin":
libName = "libonnxruntime.dylib" libName = "libonnxruntime.dylib"
default: default:
return fmt.Errorf("onnxruntime für OS %s nicht unterstützt", runtime.GOOS) return appErrorf("onnxruntime für OS %s nicht unterstützt", runtime.GOOS)
} }
dllPath := filepath.Join(root, libName) dllPath := filepath.Join(root, libName)
modelPath := filepath.Join(root, "320n.onnx") modelPath := filepath.Join(root, "320n.onnx")
if _, err := os.Stat(dllPath); err != nil { if _, err := os.Stat(dllPath); err != nil {
return fmt.Errorf("onnxruntime.dll nicht gefunden: %w", err) return appErrorf("onnxruntime.dll nicht gefunden: %w", err)
} }
if _, err := os.Stat(modelPath); err != nil { if _, err := os.Stat(modelPath); err != nil {
return fmt.Errorf("320n.onnx nicht gefunden: %w", err) return appErrorf("320n.onnx nicht gefunden: %w", err)
} }
ort.SetSharedLibraryPath(dllPath) ort.SetSharedLibraryPath(dllPath)
if err := ort.InitializeEnvironment(); err != nil { if err := ort.InitializeEnvironment(); err != nil {
return fmt.Errorf("onnxruntime init fehlgeschlagen: %w", err) return appErrorf("onnxruntime init fehlgeschlagen: %w", err)
} }
inputShape := ort.NewShape(1, 3, nsfwInputSize, nsfwInputSize) inputShape := ort.NewShape(1, 3, nsfwInputSize, nsfwInputSize)
@ -117,7 +116,7 @@ func initNSFWDetector() error {
inputTensor, err := ort.NewTensor(inputShape, inputData) inputTensor, err := ort.NewTensor(inputShape, inputData)
if err != nil { if err != nil {
ort.DestroyEnvironment() ort.DestroyEnvironment()
return fmt.Errorf("input tensor fehlgeschlagen: %w", err) return appErrorf("input tensor fehlgeschlagen: %w", err)
} }
outputShape := ort.NewShape(1, 4+nsfwNumClasses, nsfwNumAnchors) outputShape := ort.NewShape(1, 4+nsfwNumClasses, nsfwNumAnchors)
@ -125,7 +124,7 @@ func initNSFWDetector() error {
if err != nil { if err != nil {
inputTensor.Destroy() inputTensor.Destroy()
ort.DestroyEnvironment() ort.DestroyEnvironment()
return fmt.Errorf("output tensor fehlgeschlagen: %w", err) return appErrorf("output tensor fehlgeschlagen: %w", err)
} }
session, err := ort.NewAdvancedSession( session, err := ort.NewAdvancedSession(
@ -140,7 +139,7 @@ func initNSFWDetector() error {
outputTensor.Destroy() outputTensor.Destroy()
inputTensor.Destroy() inputTensor.Destroy()
ort.DestroyEnvironment() ort.DestroyEnvironment()
return fmt.Errorf("onnx session fehlgeschlagen: %w", err) return appErrorf("onnx session fehlgeschlagen: %w", err)
} }
globalNSFW.runtimeRoot = root globalNSFW.runtimeRoot = root
@ -159,13 +158,13 @@ func detectNSFWFromImage(img image.Image) ([]NsfwFrameResult, error) {
defer globalNSFW.mu.Unlock() defer globalNSFW.mu.Unlock()
if !globalNSFW.initialized || globalNSFW.session == nil { if !globalNSFW.initialized || globalNSFW.session == nil {
return nil, fmt.Errorf("nsfw detector nicht initialisiert") return nil, appErrorf("nsfw detector nicht initialisiert")
} }
fillInputTensor(globalNSFW.inputTensor.GetData(), img) fillInputTensor(globalNSFW.inputTensor.GetData(), img)
if err := globalNSFW.session.Run(); err != nil { if err := globalNSFW.session.Run(); err != nil {
return nil, fmt.Errorf("onnx run fehlgeschlagen: %w", err) return nil, appErrorf("onnx run fehlgeschlagen: %w", err)
} }
raw := globalNSFW.outputTensor.GetData() raw := globalNSFW.outputTensor.GetData()
@ -237,11 +236,11 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) {
func decodeBase64Image(imageB64 string) (image.Image, error) { func decodeBase64Image(imageB64 string) (image.Image, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64)) raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64))
if err != nil { if err != nil {
return nil, fmt.Errorf("base64 decode fehlgeschlagen: %w", err) return nil, appErrorf("base64 decode fehlgeschlagen: %w", err)
} }
img, _, err := image.Decode(bytes.NewReader(raw)) img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil { if err != nil {
return nil, fmt.Errorf("bild decode fehlgeschlagen: %w", err) return nil, appErrorf("bild decode fehlgeschlagen: %w", err)
} }
return img, nil return img, nil
} }

View File

@ -106,7 +106,7 @@ func ensureCoversDir() (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer") return "", appErrorf("covers root ist leer")
} }
if err := os.MkdirAll(root, 0o755); err != nil { if err := os.MkdirAll(root, 0o755); err != nil {
return "", err return "", err
@ -303,7 +303,7 @@ type coverModel struct {
func listModelsForCovers() ([]coverModel, error) { func listModelsForCovers() ([]coverModel, error) {
if coverModelStore == nil { if coverModelStore == nil {
return nil, fmt.Errorf("model store not set") return nil, appErrorf("model store not set")
} }
ms := coverModelStore.List() ms := coverModelStore.List()
@ -321,7 +321,7 @@ func listModelsForCovers() ([]coverModel, error) {
func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) { func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) {
category = strings.TrimSpace(category) category = strings.TrimSpace(category)
if category == "" { if category == "" {
return "", fmt.Errorf("category empty") return "", appErrorf("category empty")
} }
select { select {
@ -346,7 +346,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
} }
} }
if len(cands) == 0 { if len(cands) == 0 {
return "", fmt.Errorf("no model with tag") return "", appErrorf("no model with tag")
} }
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] }) rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
@ -354,7 +354,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
s := getSettings() s := getSettings()
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir) doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
if derr != nil || strings.TrimSpace(doneAbs) == "" { if derr != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir resolve failed: %v", derr) return "", appErrorf("doneDir resolve failed: %v", derr)
} }
type candFile struct { type candFile struct {
@ -424,7 +424,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
} }
} }
return "", fmt.Errorf("no downloads/thumbs for category") return "", appErrorf("no downloads/thumbs for category")
} }
var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`) var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`)
@ -436,13 +436,13 @@ func sanitizeCoverKey(category string) (string, error) {
c = "tag_" + hex.EncodeToString(sum[:8]) c = "tag_" + hex.EncodeToString(sum[:8])
} }
if c == "" { if c == "" {
return "", fmt.Errorf("category fehlt") return "", appErrorf("category fehlt")
} }
c = strings.ReplaceAll(c, " ", "_") c = strings.ReplaceAll(c, " ", "_")
c = coverKeyRe.ReplaceAllString(c, "_") c = coverKeyRe.ReplaceAllString(c, "_")
c = strings.Trim(c, "._-") c = strings.Trim(c, "._-")
if c == "" { if c == "" {
return "", fmt.Errorf("category ungültig") return "", appErrorf("category ungültig")
} }
if len(c) > 120 { if len(c) > 120 {
c = c[:120] c = c[:120]
@ -481,7 +481,7 @@ func coverPathForCategory(key string, ext string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer") return "", appErrorf("covers root ist leer")
} }
if ext == "" { if ext == "" {
ext = ".jpg" ext = ".jpg"
@ -507,37 +507,37 @@ func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) { func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL) rawURL = strings.TrimSpace(rawURL)
if rawURL == "" { if rawURL == "" {
return nil, "", fmt.Errorf("src fehlt") return nil, "", appErrorf("src fehlt")
} }
// local: only /generated/... // local: only /generated/...
if strings.HasPrefix(rawURL, "/") { if strings.HasPrefix(rawURL, "/") {
clean := path.Clean(rawURL) clean := path.Clean(rawURL)
if !strings.HasPrefix(clean, "/generated/") { if !strings.HasPrefix(clean, "/generated/") {
return nil, "", fmt.Errorf("src ungültig") return nil, "", appErrorf("src ungültig")
} }
if strings.Contains(clean, "..") { if strings.Contains(clean, "..") {
return nil, "", fmt.Errorf("src ungültig") return nil, "", appErrorf("src ungültig")
} }
rel := strings.TrimPrefix(clean, "/") rel := strings.TrimPrefix(clean, "/")
abs, err := resolvePathRelativeToApp(rel) abs, err := resolvePathRelativeToApp(rel)
if err != nil || strings.TrimSpace(abs) == "" { if err != nil || strings.TrimSpace(abs) == "" {
return nil, "", fmt.Errorf("src ungültig") return nil, "", appErrorf("src ungültig")
} }
f, err := os.Open(abs) f, err := os.Open(abs)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err) return nil, "", appErrorf("download failed: %v", err)
} }
defer f.Close() defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024)) b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
if err != nil { if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err) return nil, "", appErrorf("download failed: %v", err)
} }
if len(b) == 0 { if len(b) == 0 {
return nil, "", fmt.Errorf("download empty") return nil, "", appErrorf("download empty")
} }
ext := strings.ToLower(filepath.Ext(abs)) ext := strings.ToLower(filepath.Ext(abs))
@ -556,10 +556,10 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" { if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", fmt.Errorf("src ungültig") return nil, "", appErrorf("src ungültig")
} }
if u.Scheme != "http" && u.Scheme != "https" { if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("src schema nicht erlaubt") return nil, "", appErrorf("src schema nicht erlaubt")
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
@ -580,7 +580,7 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) return nil, "", appErrorf("download failed: HTTP %d", resp.StatusCode)
} }
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
@ -588,7 +588,7 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin
return nil, "", err return nil, "", err
} }
if len(b) == 0 { if len(b) == 0 {
return nil, "", fmt.Errorf("download empty") return nil, "", appErrorf("download empty")
} }
return b, resp.Header.Get("Content-Type"), nil return b, resp.Header.Get("Content-Type"), nil
@ -1205,14 +1205,14 @@ func extractLastFrameJPG(path string) ([]byte, error) {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ffmpeg last-frame jpg: timeout") return nil, appErrorf("ffmpeg last-frame jpg: timeout")
} }
return nil, fmt.Errorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, appErrorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame jpg: empty output") return nil, appErrorf("ffmpeg last-frame jpg: empty output")
} }
return b, nil return b, nil
@ -1246,11 +1246,11 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, appErrorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg frame-at-time jpg: empty output") return nil, appErrorf("ffmpeg frame-at-time jpg: empty output")
} }
return b, nil return b, nil
} }
@ -1294,11 +1294,11 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, appErrorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: empty output") return nil, appErrorf("ffmpeg last-frame scaled jpg: empty output")
} }
return b, nil return b, nil
} }
@ -1333,11 +1333,11 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, appErrorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: empty output") return nil, appErrorf("ffmpeg first-frame scaled jpg: empty output")
} }
return b, nil return b, nil
} }
@ -1345,7 +1345,7 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) { func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) {
m3u8URL = strings.TrimSpace(m3u8URL) m3u8URL = strings.TrimSpace(m3u8URL)
if m3u8URL == "" { if m3u8URL == "" {
return nil, fmt.Errorf("m3u8 leer") return nil, appErrorf("m3u8 leer")
} }
if strings.TrimSpace(userAgent) == "" { if strings.TrimSpace(userAgent) == "" {
@ -1387,12 +1387,12 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String())) return nil, appErrorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
b := out.Bytes() b := out.Bytes()
if len(b) == 0 { if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output") return nil, appErrorf("ffmpeg live m3u8 jpg: empty output")
} }
return b, nil return b, nil
@ -1504,7 +1504,7 @@ func fallbackPreviewImageAssetPathForProvider(provider string) (string, error) {
case "mfc": case "mfc":
return "assets/img/mfc.jpg", nil return "assets/img/mfc.jpg", nil
default: default:
return "", fmt.Errorf("unknown provider") return "", appErrorf("unknown provider")
} }
} }
@ -1969,7 +1969,7 @@ func previewImageURLForJob(job *RecordJob) string {
func imageBytesToJPG(raw []byte, quality int) ([]byte, error) { func imageBytesToJPG(raw []byte, quality int) ([]byte, error) {
if len(raw) == 0 { if len(raw) == 0 {
return nil, fmt.Errorf("empty image") return nil, appErrorf("empty image")
} }
if quality <= 0 || quality > 100 { if quality <= 0 || quality > 100 {
quality = 80 quality = 80
@ -1985,7 +1985,7 @@ func imageBytesToJPG(raw []byte, quality int) ([]byte, error) {
return nil, err return nil, err
} }
if buf.Len() == 0 { if buf.Len() == 0 {
return nil, fmt.Errorf("jpeg encode returned empty data") return nil, appErrorf("jpeg encode returned empty data")
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }

View File

@ -4,6 +4,7 @@ package main
import ( import (
"math" "math"
"sort"
"strings" "strings"
) )
@ -58,23 +59,658 @@ func ratingEffectiveDurationSeconds(seconds float64) float64 {
return kneeSeconds * math.Log1p(seconds/kneeSeconds) return kneeSeconds * math.Log1p(seconds/kneeSeconds)
} }
func nsfwSegmentSeverityWeight(label string) float64 { func segmentSeverityWeight(label string) float64 {
switch strings.ToLower(strings.TrimSpace(label)) { label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return 0.50
}
// -------------------------
// Kombi-Highlights
// -------------------------
if strings.HasPrefix(label, "combo:") {
return comboSegmentSeverityWeight(label)
}
// -------------------------
// 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)
}
func isPersonSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(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:")
switch label {
case "person",
"person_male",
"person_female",
"person_unknown",
"male_person",
"female_person",
"people_male",
"people_female":
return true
default:
return false
}
}
func detectorSeverityWeight(label string) float64 {
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": case "female_genitalia_exposed":
return 1.00 return 1.00
case "female_breast_exposed": case "male_genitalia_exposed":
return 0.95 return 0.95
case "anus_exposed": case "anus_exposed":
return 0.85 return 0.90
case "male_genitalia_exposed": case "female_breast_exposed", "breast_exposed":
return 0.80 return 0.80
case "buttocks_exposed": case "buttocks_exposed":
return 0.65 return 0.65
default: default:
return 0.50 return 0.50
} }
} }
func bodyPartSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
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
case "mouth", "face":
return 0.45
case "hands":
return 0.35
default:
return 0.50
}
}
func objectSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "dildo", "vibrator", "strapon", "buttplug", "sex_toy":
return 0.85
case "handcuffs", "blindfold", "collar":
return 0.55
case "shower":
return 0.40
case "towel":
return 0.25
default:
return 0.45
}
}
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
case "stockings", "heels":
return 0.35
case "skirt", "dress", "hotpants", "croptop":
return 0.30
default:
return 0.30
}
}
func positionSeverityWeight(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
}
}
type ratingSignalSet struct {
Position float64
Body float64
Object float64
Clothing float64
HasPosition bool
HasBody bool
HasObject bool
HasClothing bool
}
func isKnownPositionLabel(label string) bool {
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
}
}
func ratingSignalSetAddLabel(set *ratingSignalSet, label string) {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || isPersonSegmentLabel(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
}
if w > 0 {
set.HasPosition = true
}
case strings.HasPrefix(label, "body:"):
w := bodyPartSeverityWeight(strings.TrimPrefix(label, "body:"))
if w > set.Body {
set.Body = w
}
if w > 0 {
set.HasBody = true
}
case strings.HasPrefix(label, "object:"):
w := objectSeverityWeight(strings.TrimPrefix(label, "object:"))
if w > set.Object {
set.Object = w
}
if w > 0 {
set.HasObject = true
}
case strings.HasPrefix(label, "clothing:"):
w := clothingSeverityWeight(strings.TrimPrefix(label, "clothing:"))
if w > set.Clothing {
set.Clothing = w
}
if w > 0 {
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)
}
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)
}
}
}
func ratingSignalSetFromLabel(label string) ratingSignalSet {
label = strings.ToLower(strings.TrimSpace(label))
var set ratingSignalSet
if strings.HasPrefix(label, "combo:") {
raw := strings.TrimPrefix(label, "combo:")
for _, part := range strings.Split(raw, "+") {
ratingSignalSetAddLabel(&set, part)
}
return set
}
ratingSignalSetAddLabel(&set, label)
return set
}
func contextualSegmentSeverityWeight(label string) float64 {
set := ratingSignalSetFromLabel(label)
hasAny :=
set.HasPosition ||
set.HasBody ||
set.HasObject ||
set.HasClothing
if !hasAny {
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
// Echte Kombi-Boni.
if set.HasBody {
score += 0.04
}
if set.HasObject {
score += 0.035
}
if set.HasClothing {
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)
}
return ratingClamp01(score)
}
// Ohne Position: Kontext darf zählen, aber gedeckelt.
score =
0.58*set.Body +
0.28*set.Object +
0.14*set.Clothing
if set.HasBody && set.HasObject {
score += 0.08
}
if set.HasBody && set.HasClothing {
score += 0.03
}
// Kleidung alleine niemals stark bewerten.
if set.HasClothing && !set.HasBody && !set.HasObject {
score = math.Min(score, 0.32)
}
// Keine Position => nicht höher als "mittel-hoch".
score = math.Min(score, 0.68)
return ratingClamp01(score)
}
func comboSegmentSeverityWeight(label string) float64 {
raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(label)), "combo:")
if raw == "" {
return 0.00
}
parts := strings.Split(raw, "+")
var weights []float64
hasPersonContext := false
for _, part := range parts {
part = strings.TrimSpace(part)
if 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)
}
}
// Nur Person in der Combo => kein Rating-/Highlight-Gewicht.
if len(weights) == 0 {
return 0.00
}
var sum float64
var maxWeight float64
for _, weight := range weights {
sum += weight
if weight > maxWeight {
maxWeight = weight
}
}
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
}
func normalizeSegmentLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(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:")
return strings.TrimSpace(label)
}
func mergeAdjacentAISegments(segments []aiSegmentMeta, maxGapSec 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)
for _, s := range segments {
if strings.TrimSpace(s.Label) == "" {
continue
}
if s.DurationSeconds <= 0 {
s.DurationSeconds = s.EndSeconds - s.StartSeconds
}
if s.EndSeconds <= s.StartSeconds {
continue
}
key := normalizeSegmentLabel(s.Label)
if key == "" {
continue
}
byLabel[key] = append(byLabel[key], 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)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].StartSeconds != out[j].StartSeconds {
return out[i].StartSeconds < out[j].StartSeconds
}
if out[i].EndSeconds != out[j].EndSeconds {
return out[i].EndSeconds < out[j].EndSeconds
}
return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label)
})
return out
}
func ratingConfidenceWeight(conf float64) float64 { func ratingConfidenceWeight(conf float64) float64 {
conf = ratingClamp01(conf) conf = ratingClamp01(conf)
@ -87,13 +723,13 @@ func ratingConfidenceWeight(conf float64) float64 {
func starsFromNSFWScore(score float64) int { func starsFromNSFWScore(score float64) int {
switch { switch {
case score < 15: case score < 18:
return 1 return 1
case score < 35: case score < 38:
return 2 return 2
case score < 58: case score < 60:
return 3 return 3
case score < 78: case score < 80:
return 4 return 4
default: default:
return 5 return 5
@ -101,6 +737,8 @@ func starsFromNSFWScore(score float64) int {
} }
func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
segments = mergeAdjacentAISegments(segments, 5.0)
r := &aiRatingMeta{ r := &aiRatingMeta{
Score: 0, Score: 0,
Stars: 1, Stars: 1,
@ -115,11 +753,20 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM
var totalFlagged float64 var totalFlagged float64
var totalWeighted float64 var totalWeighted float64
var totalEffectiveWeighted float64 var totalEffectiveWeighted float64
var positionEffectiveWeighted float64
var contextEffectiveWeighted float64
var peakQuality float64
var longest float64 var longest float64
var confSum float64 var confSum float64
var n int var n int
for _, s := range segments { for _, s := range segments {
if isPersonSegmentLabel(s.Label) {
continue
}
segDur := s.DurationSeconds segDur := s.DurationSeconds
if segDur <= 0 { if segDur <= 0 {
segDur = s.EndSeconds - s.StartSeconds segDur = s.EndSeconds - s.StartSeconds
@ -128,13 +775,37 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM
continue continue
} }
sev := nsfwSegmentSeverityWeight(s.Label) sev := contextualSegmentSeverityWeight(s.Label)
if sev <= 0 {
continue
}
conf := ratingClamp01(s.Score) conf := ratingClamp01(s.Score)
weight := sev * ratingConfidenceWeight(conf) confWeight := ratingConfidenceWeight(conf)
quality := sev * confWeight
if quality <= 0 {
continue
}
effectiveDur := ratingEffectiveDurationSeconds(segDur)
weightedDur := segDur * quality
effectiveWeightedDur := effectiveDur * quality
totalFlagged += segDur totalFlagged += segDur
totalWeighted += segDur * weight totalWeighted += weightedDur
totalEffectiveWeighted += ratingEffectiveDurationSeconds(segDur) * weight totalEffectiveWeighted += effectiveWeightedDur
set := ratingSignalSetFromLabel(s.Label)
if set.HasPosition {
positionEffectiveWeighted += effectiveWeightedDur
} else {
contextEffectiveWeighted += effectiveWeightedDur
}
if quality > peakQuality {
peakQuality = quality
}
confSum += conf confSum += conf
n++ n++
@ -152,22 +823,39 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM
weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / float64(n) avgConfidence := confSum / float64(n)
effectiveWeightedSecondsPerMinute := totalEffectiveWeighted / videoMinutes
// Weichere Normalisierung statt harter Sättigung. positionEffectiveWeightedPerMinute := positionEffectiveWeighted / videoMinutes
// Das reduziert 1/5-Ausreißer und verteilt mehr Fälle auf 24 Sterne. contextEffectiveWeightedPerMinute := contextEffectiveWeighted / videoMinutes
densityNorm := ratingSoftCap(effectiveWeightedSecondsPerMinute, 8.0)
// 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) coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22)
frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25) frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.40)
longestNorm := ratingSoftCap(longest, 25.0) longestNorm := ratingSoftCap(longest, 28.0)
confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60) confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60)
raw := raw :=
0.38*densityNorm + 0.34*peakNorm +
0.30*coverageNorm + 0.28*positionDensityNorm +
0.12*frequencyNorm + 0.14*coverageNorm +
0.10*longestNorm + 0.10*contextDensityNorm +
0.10*confNorm 0.06*longestNorm +
0.04*frequencyNorm +
0.04*confNorm
// Sicherheits-Caps:
// Ohne Positionssignal soll Kontext alleine nicht auf 45 Sterne kippen.
if positionEffectiveWeighted <= 0 {
raw = math.Min(raw, 0.58)
}
// Sehr kurze/spärliche Treffer nicht überbewerten.
if totalFlagged < 4.0 && n <= 1 {
raw = math.Min(raw, 0.42)
}
score := ratingRound(ratingClamp01(raw)*100, 1) score := ratingRound(ratingClamp01(raw)*100, 1)

View File

@ -1373,7 +1373,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
writeErr := func(code int, msg string) { writeErr := func(code int, msg string) {
if tw.wrote { if tw.wrote {
fmt.Println("[recordVideo] late error (headers already sent):", code, msg) appLogln("[recordVideo] late error (headers already sent):", code, msg)
return return
} }
http.Error(w, msg, code) http.Error(w, msg, code)

View File

@ -2,7 +2,6 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -29,7 +28,7 @@ func safeBasenameQuery(r *http.Request, key string) (string, bool, error) {
} }
dec = strings.TrimSpace(dec) dec = strings.TrimSpace(dec)
if !isSafeBasename(dec) { if !isSafeBasename(dec) {
return "", false, fmt.Errorf("invalid basename") return "", false, appErrorf("invalid basename")
} }
return dec, true, nil return dec, true, nil
} }
@ -252,7 +251,7 @@ func resolveDoneFileByName(doneAbs string, file string) (full string, from strin
return p, "keep", fi, nil return p, "keep", fi, nil
} }
return "", "", nil, fmt.Errorf("not found") return "", "", nil, appErrorf("not found")
} }
func isTrashPath(p string) bool { func isTrashPath(p string) bool {

View File

@ -6,7 +6,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -43,12 +42,12 @@ func RecordStream(
loadFreshHLS := func() (*selectedHLSStream, error) { loadFreshHLS := func() (*selectedHLSStream, error) {
body, err := hc.FetchPage(ctx, pageURL, httpCookie) body, err := hc.FetchPage(ctx, pageURL, httpCookie)
if err != nil { if err != nil {
return nil, fmt.Errorf("seite laden: %w", err) return nil, appErrorf("seite laden: %w", err)
} }
hlsURL, err := ParseStream(body) hlsURL, err := ParseStream(body)
if err != nil { if err != nil {
return nil, fmt.Errorf("stream-parsing: %w", err) return nil, appErrorf("stream-parsing: %w", err)
} }
hlsURL = strings.TrimSpace(hlsURL) hlsURL = strings.TrimSpace(hlsURL)
@ -64,7 +63,7 @@ func RecordStream(
pageURL, pageURL,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("variant-playlist: %w", err) return nil, appErrorf("variant-playlist: %w", err)
} }
if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
return nil, errors.New("leere final video url") return nil, errors.New("leere final video url")
@ -119,7 +118,7 @@ func RecordStream(
if mergeAfterRun && fileExistsNonEmpty(attemptOut) { if mergeAfterRun && fileExistsNonEmpty(attemptOut) {
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil { if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
return fmt.Errorf("retry-chunk merge failed: %w", aerr) return appErrorf("retry-chunk merge failed: %w", aerr)
} }
} }
@ -147,7 +146,7 @@ func RecordStream(
refreshes++ refreshes++
if refreshes > maxPlaylistRefreshes { if refreshes > maxPlaylistRefreshes {
return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
} }
select { select {
@ -193,14 +192,14 @@ func ParseStream(html string) (string, error) {
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
) )
if err != nil { if err != nil {
return "", fmt.Errorf("Unicode-decode failed: %w", err) return "", appErrorf("Unicode-decode failed: %w", err)
} }
var rd struct { var rd struct {
HLSSource string `json:"hls_source"` HLSSource string `json:"hls_source"`
} }
if err := json.Unmarshal([]byte(decoded), &rd); err != nil { if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
return "", fmt.Errorf("JSON-parse failed: %w", err) return "", appErrorf("JSON-parse failed: %w", err)
} }
if rd.HLSSource == "" { if rd.HLSSource == "" {
return "", errors.New("kein HLS-Quell-URL im JSON") return "", errors.New("kein HLS-Quell-URL im JSON")

View File

@ -147,19 +147,19 @@ func getWantedResolutionPlaylistWithHeaders(
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) return nil, appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
} }
rawBody, err := io.ReadAll(resp.Body) rawBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("m3u8 read: %w", err) return nil, appErrorf("m3u8 read: %w", err)
} }
masterText := string(rawBody) masterText := string(rawBody)
playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true) playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true)
if err != nil { if err != nil {
return nil, fmt.Errorf("m3u8 parse: %w", err) return nil, appErrorf("m3u8 parse: %w", err)
} }
// Schon Media-Playlist // Schon Media-Playlist
@ -354,7 +354,7 @@ func appendFileAndRemove(dstPath, srcPath string) error {
dstPath = strings.TrimSpace(dstPath) dstPath = strings.TrimSpace(dstPath)
if srcPath == "" || dstPath == "" { if srcPath == "" || dstPath == "" {
return fmt.Errorf("append path leer") return appErrorf("append path leer")
} }
if filepath.Clean(srcPath) == filepath.Clean(dstPath) { if filepath.Clean(srcPath) == filepath.Clean(dstPath) {
return nil return nil
@ -423,13 +423,13 @@ func handleM3U8Mode(
u, err := url.Parse(videoURL) u, err := url.Parse(videoURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") { if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige video URL: %q", videoURL) return appErrorf("ungültige video URL: %q", videoURL)
} }
if audioURL != "" { if audioURL != "" {
au, err := url.Parse(audioURL) au, err := url.Parse(audioURL)
if err != nil || (au.Scheme != "http" && au.Scheme != "https") { if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
return fmt.Errorf("ungültige audio URL: %q", audioURL) return appErrorf("ungültige audio URL: %q", audioURL)
} }
} }
@ -440,7 +440,7 @@ func handleM3U8Mode(
ext := strings.ToLower(filepath.Ext(outFile)) ext := strings.ToLower(filepath.Ext(outFile))
if ext != ".ts" && ext != ".mp4" { if ext != ".ts" && ext != ".mp4" {
return fmt.Errorf("nicht unterstützte output-endung: %q", ext) return appErrorf("nicht unterstützte output-endung: %q", ext)
} }
// Wichtig: // Wichtig:
@ -562,9 +562,9 @@ func handleM3U8Mode(
if err != nil { if err != nil {
msg := strings.TrimSpace(stderr.String()) msg := strings.TrimSpace(stderr.String())
if msg != "" { if msg != "" {
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg)
} }
return fmt.Errorf("ffmpeg m3u8 failed: %w", err) return appErrorf("ffmpeg m3u8 failed: %w", err)
} }
if job != nil { if job != nil {

View File

@ -54,9 +54,9 @@ func RecordStreamMFC(
if time.Now().After(deadline) { if time.Now().After(deadline) {
if lastSt == nil { if lastSt == nil {
return fmt.Errorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax) return appErrorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax)
} }
return fmt.Errorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt) return appErrorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt)
} }
@ -66,10 +66,10 @@ func RecordStreamMFC(
// ✅ erst jetzt die Video URL holen (weil public) // ✅ erst jetzt die Video URL holen (weil public)
stream, err := mfc.GetSelectedStream(false) stream, err := mfc.GetSelectedStream(false)
if err != nil { if err != nil {
return fmt.Errorf("mfc get video url: %w", err) return appErrorf("mfc get video url: %w", err)
} }
if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
return fmt.Errorf("mfc: keine m3u8 URL gefunden") return appErrorf("mfc: keine m3u8 URL gefunden")
} }
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
@ -127,7 +127,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
midInt, err := strconv.Atoi(midBase) midInt, err := strconv.Atoi(midBase)
if err != nil { if err != nil {
return "", fmt.Errorf("model-id parse error: %w", err) return "", appErrorf("model-id parse error: %w", err)
} }
mid := 100000000 + midInt mid := 100000000 + midInt
a := "" a := ""
@ -180,7 +180,7 @@ func (m *MyFreeCams) GetStatus() (Status, error) {
return StatusNotExist, nil return StatusNotExist, nil
} }
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) return StatusUnknown, appErrorf("HTTP %d", resp.StatusCode)
} }
// wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML)
@ -202,7 +202,7 @@ func (m *MyFreeCams) GetStatus() (Status, error) {
raw := string(bodyBytes[start : start+end]) raw := string(bodyBytes[start : start+end])
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil { if err != nil {
return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) return StatusUnknown, appErrorf("tracking url invalid: %w", err)
} }
qs := u.Query() qs := u.Query()
if qs.Get("model_id") == "" { if qs.Get("model_id") == "" {
@ -255,7 +255,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return err return err
} }
if st != StatusPublic { if st != StatusPublic {
return fmt.Errorf("Stream ist nicht live (Status: %s)", st) return appErrorf("Stream ist nicht live (Status: %s)", st)
} }
stream, err := mfc.GetSelectedStream(false) stream, err := mfc.GetSelectedStream(false)
@ -277,12 +277,12 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) return "", appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
} }
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil { if err != nil {
return "", fmt.Errorf("m3u8 parse: %w", err) return "", appErrorf("m3u8 parse: %w", err)
} }
if listType == m3u8.MEDIA { if listType == m3u8.MEDIA {
return playlistURL, nil return playlistURL, nil

View File

@ -190,7 +190,7 @@ func scrubberIndexToTime(index, count int, durationSec float64) float64 {
func lookupDurationForScrubber(r *http.Request) (float64, error) { func lookupDurationForScrubber(r *http.Request) (float64, error) {
path, ok, _, _ := resolvePlayablePathFromQuery(r) path, ok, _, _ := resolvePlayablePathFromQuery(r)
if !ok || strings.TrimSpace(path) == "" { if !ok || strings.TrimSpace(path) == "" {
return 0, fmt.Errorf("unable to resolve file") return 0, appErrorf("unable to resolve file")
} }
// best-effort meta // best-effort meta
@ -335,7 +335,7 @@ func statOutputForLog(path string) string {
func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) { func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" { if path == "" {
return nil, fmt.Errorf("path empty") return nil, appErrorf("path empty")
} }
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
@ -350,11 +350,11 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error
if err != nil { if err != nil {
lastErr = err lastErr = err
} else if fi == nil { } else if fi == nil {
lastErr = fmt.Errorf("stat returned nil") lastErr = appErrorf("stat returned nil")
} else if fi.IsDir() { } else if fi.IsDir() {
lastErr = fmt.Errorf("path is dir") lastErr = appErrorf("path is dir")
} else { } else {
lastErr = fmt.Errorf("file size is 0") lastErr = appErrorf("file size is 0")
} }
if time.Now().After(deadline) { if time.Now().After(deadline) {
@ -364,7 +364,7 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error
} }
if lastErr == nil { if lastErr == nil {
lastErr = fmt.Errorf("usable output not ready in time") lastErr = appErrorf("usable output not ready in time")
} }
return nil, lastErr return nil, lastErr
} }
@ -433,7 +433,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
provider := detectProvider(url) provider := detectProvider(url)
if provider != "chaturbate" && provider != "mfc" { if provider != "chaturbate" && provider != "mfc" {
return nil, fmt.Errorf("unsupported provider: %q", url) return nil, appErrorf("unsupported provider: %q", url)
} }
if err := ensureDiskGuardAllowsStart(); err != nil { if err := ensureDiskGuardAllowsStart(); err != nil {
@ -470,7 +470,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
} }
if active >= max { if active >= max {
return nil, fmt.Errorf("Download-Limit erreicht (%d/%d aktiv)", active, max) return nil, appErrorf("Download-Limit erreicht (%d/%d aktiv)", active, max)
} }
} }
@ -520,18 +520,18 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
func requireNonEmptyRegularFile(path string, label string) error { func requireNonEmptyRegularFile(path string, label string) error {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" { if path == "" {
return fmt.Errorf("%s: path empty", label) return appErrorf("%s: path empty", label)
} }
fi, err := os.Stat(path) fi, err := os.Stat(path)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", label, err) return appErrorf("%s: %w", label, err)
} }
if fi == nil || fi.IsDir() { if fi == nil || fi.IsDir() {
return fmt.Errorf("%s: not a file", label) return appErrorf("%s: not a file", label)
} }
if fi.Size() <= 0 { if fi.Size() <= 0 {
return fmt.Errorf("%s: empty file", label) return appErrorf("%s: empty file", label)
} }
return nil return nil
} }
@ -541,16 +541,16 @@ func buildPostworkTempMP4Path(tsPath string) (string, error) {
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
return "", fmt.Errorf("doneDir auflösung fehlgeschlagen: %w", err) return "", appErrorf("doneDir auflösung fehlgeschlagen: %w", err)
} }
doneAbs = strings.TrimSpace(doneAbs) doneAbs = strings.TrimSpace(doneAbs)
if doneAbs == "" { if doneAbs == "" {
return "", fmt.Errorf("doneDir ist leer") return "", appErrorf("doneDir ist leer")
} }
tmpDir := filepath.Join(doneAbs, ".postwork_tmp") tmpDir := filepath.Join(doneAbs, ".postwork_tmp")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return "", fmt.Errorf("postwork tmp dir erstellen fehlgeschlagen: %w", err) return "", appErrorf("postwork tmp dir erstellen fehlgeschlagen: %w", err)
} }
base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4" base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4"
@ -581,14 +581,14 @@ func runPrimaryPostworkPipeline(
fi, err := os.Stat(tsPath) fi, err := os.Stat(tsPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return out, fmt.Errorf("ts invalid before remux") return out, appErrorf("ts invalid before remux")
} }
inSize := fi.Size() inSize := fi.Size()
mp4Path, err := buildPostworkTempMP4Path(tsPath) mp4Path, err := buildPostworkTempMP4Path(tsPath)
if err != nil { if err != nil {
return tsPath, fmt.Errorf("postwork temp mp4 path failed: %w", err) return tsPath, appErrorf("postwork temp mp4 path failed: %w", err)
} }
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
@ -620,7 +620,7 @@ func runPrimaryPostworkPipeline(
) )
if err != nil { if err != nil {
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("ts remux failed: %w", err) return tsPath, appErrorf("ts remux failed: %w", err)
} }
if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil { if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil {
@ -629,13 +629,13 @@ func runPrimaryPostworkPipeline(
} }
if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil { if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil {
fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err) appLogln("⚠️ remux mp4 decode validation failed, fallback to reencode:", err)
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil { if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil {
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("remux invalid (%v), reencode fallback failed: %w", err, rerr) return tsPath, appErrorf("remux invalid (%v), reencode fallback failed: %w", err, rerr)
} }
if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil { if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil {
@ -645,7 +645,7 @@ func runPrimaryPostworkPipeline(
if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil { if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil {
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("reencoded mp4 still invalid: %w", err2) return tsPath, appErrorf("reencoded mp4 still invalid: %w", err2)
} }
} }
@ -673,9 +673,9 @@ func runPrimaryPostworkPipeline(
} }
if err != nil { if err != nil {
return fallbackOut, fmt.Errorf("moveToDoneDir failed: %w", err) return fallbackOut, appErrorf("moveToDoneDir failed: %w", err)
} }
return fallbackOut, fmt.Errorf("moveToDoneDir returned empty path") return fallbackOut, appErrorf("moveToDoneDir returned empty path")
} }
out = strings.TrimSpace(moved) out = strings.TrimSpace(moved)
@ -691,7 +691,7 @@ func runPrimaryPostworkPipeline(
if cleanupTSAfterMove != "" { if cleanupTSAfterMove != "" {
if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) { if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) {
fmt.Println("⚠️ ts cleanup after successful move:", err) appLogln("⚠️ ts cleanup after successful move:", err)
} }
purgeDurationCacheForPath(cleanupTSAfterMove) purgeDurationCacheForPath(cleanupTSAfterMove)
} }
@ -711,14 +711,14 @@ func runPrimaryPostworkPipeline(
} }
if _, err := ensureMetaForVideoCtx(ctx, out, job.SourceURL); err != nil { if _, err := ensureMetaForVideoCtx(ctx, out, job.SourceURL); err != nil {
return out, fmt.Errorf("meta.json generation failed: %w", err) return out, appErrorf("meta.json generation failed: %w", err)
} }
attachMetaToJobBestEffort(ctx, job, out) attachMetaToJobBestEffort(ctx, job, out)
assetID := assetIDFromVideoPath(out) assetID := assetIDFromVideoPath(out)
if assetID == "" { if assetID == "" {
return out, fmt.Errorf("asset id missing after move") return out, appErrorf("asset id missing after move")
} }
_, thumbPath, previewPath, _, metaPath, err := assetPathsForID(assetID) _, thumbPath, previewPath, _, metaPath, err := assetPathsForID(assetID)
@ -763,7 +763,7 @@ func runPrimaryPostworkPipeline(
} }
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
return out, fmt.Errorf("primary assets failed: %w", err) return out, appErrorf("primary assets failed: %w", err)
} }
if err := requireNonEmptyRegularFile(thumbPath, "preview.jpg"); err != nil { if err := requireNonEmptyRegularFile(thumbPath, "preview.jpg"); err != nil {
@ -859,7 +859,7 @@ func runChaturbateRecording(
s := getSettings() s := getSettings()
recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordDirAbs) == "" { if err != nil || strings.TrimSpace(recordDirAbs) == "" {
return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) return appErrorf("recordDir auflösung fehlgeschlagen: %v", err)
} }
_ = os.MkdirAll(recordDirAbs, 0o755) _ = os.MkdirAll(recordDirAbs, 0o755)
@ -897,7 +897,7 @@ func runMFCRecording(
s := getSettings() s := getSettings()
recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordDirAbs) == "" { if err != nil || strings.TrimSpace(recordDirAbs) == "" {
return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) return appErrorf("recordDir auflösung fehlgeschlagen: %v", err)
} }
_ = os.MkdirAll(recordDirAbs, 0o755) _ = os.MkdirAll(recordDirAbs, 0o755)
@ -1053,7 +1053,7 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
} }
if isFavoriteProtectedDownload(out, job.SourceURL) { if isFavoriteProtectedDownload(out, job.SourceURL) {
fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out)) appLogln("⭐ auto-delete skipped for favorite:", filepath.Base(out))
return false return false
} }
@ -1066,7 +1066,7 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool {
removeJobAndPublish(job) removeJobAndPublish(job)
return true return true
} else { } else {
fmt.Println("⚠️ auto-delete before enqueue failed:", derr) appLogln("⚠️ auto-delete before enqueue failed:", derr)
} }
return false return false
@ -1292,15 +1292,17 @@ func publishDeferredPhase(fileName, assetID, queue, phase, state, label string,
func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error { func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error {
outPath = strings.TrimSpace(outPath) outPath = strings.TrimSpace(outPath)
if outPath == "" { if outPath == "" {
return fmt.Errorf("output path leer") return appErrorf("output path leer")
} }
fileName := filepath.Base(outPath) fileName := filepath.Base(outPath)
assetID := assetIDFromVideoPath(outPath) assetID := assetIDFromVideoPath(outPath)
if assetID == "" { if assetID == "" {
return fmt.Errorf("asset id fehlt") return appErrorf("asset id fehlt")
} }
requiredGoals := requiredAnalyzeGoals()
// best effort sourceURL aus meta.json nachziehen, falls leer // best effort sourceURL aus meta.json nachziehen, falls leer
if strings.TrimSpace(sourceURL) == "" { if strings.TrimSpace(sourceURL) == "" {
if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil { if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil {
@ -1337,7 +1339,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady { if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady {
if err == nil { if err == nil {
err = fmt.Errorf("meta konnte nicht erzeugt werden") err = appErrorf("meta konnte nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err)
return err return err
@ -1356,7 +1358,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
if err != nil || !assetsTruthForVideo(outPath).ThumbReady { if err != nil || !assetsTruthForVideo(outPath).ThumbReady {
if err == nil { if err == nil {
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden") err = appErrorf("vorschaubild konnte nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err)
return err return err
@ -1375,7 +1377,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
if err != nil || !assetsTruthForVideo(outPath).TeaserReady { if err != nil || !assetsTruthForVideo(outPath).TeaserReady {
if err == nil { if err == nil {
err = fmt.Errorf("teaser konnte nicht erzeugt werden") err = appErrorf("teaser konnte nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err)
return err return err
@ -1385,9 +1387,12 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
truth = assetsTruthForVideo(outPath) truth = assetsTruthForVideo(outPath)
} }
// Ab hier erst die langsamen Hintergrundphasen
var firstErr error var firstErr error
// -------------------------------------------------
// Langsame Hintergrundphase: Sprite
// -------------------------------------------------
if !truth.SpritesReady { if !truth.SpritesReady {
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil)
@ -1397,7 +1402,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
if err != nil || !assetsTruthForVideo(outPath).SpritesReady { if err != nil || !assetsTruthForVideo(outPath).SpritesReady {
if err == nil { if err == nil {
err = fmt.Errorf("sprites konnten nicht erzeugt werden") err = appErrorf("sprites konnten nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err)
if firstErr == nil { if firstErr == nil {
@ -1405,7 +1410,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
} else { } else {
if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil { if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil {
fmt.Println("⚠️ sprite tail blackout check failed:", outPath, checkErr) appLogln("⚠️ sprite tail blackout check failed:", outPath, checkErr)
} }
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
@ -1414,23 +1419,40 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
truth = assetsTruthForVideo(outPath) truth = assetsTruthForVideo(outPath)
if !truth.AnalyzeReady { // Ohne Sprite kann keine Sprite-basierte Analyse laufen.
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analyse", nil) if !truth.SpritesReady {
if firstErr == nil {
firstErr = appErrorf("Analysen übersprungen: preview-sprite.jpg fehlt")
}
return firstErr
}
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw") // -------------------------------------------------
// Langsame Hintergrundphase: NSFW + Highlights zusammen
// -------------------------------------------------
analyzeReady := hasAIResultsForAllOutputGoals(outPath, requiredGoals)
if !analyzeReady {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analysen", nil)
_, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, outPath, sourceURL)
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil { if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
return cerr return cerr
} }
if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady {
analyzeReady = hasAIResultsForAllOutputGoals(outPath, requiredGoals)
if err != nil || !analyzeReady {
if err == nil { if err == nil {
err = fmt.Errorf("Analyse konnte nicht erzeugt werden") err = appErrorf("Analysen konnten nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen fehlgeschlagen", err)
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
} else { } else {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analyse", nil) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analysen", nil)
} }
} }
@ -1500,7 +1522,7 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat
}) })
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok { if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
fmt.Println("⚠️ deferred enrichment enqueue failed:", out) appLogln("⚠️ deferred enrichment enqueue failed:", out)
} }
if postTarget == JobStopped { if postTarget == JobStopped {

View File

@ -3,7 +3,6 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -33,6 +32,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/events/stream", eventsStream) api.HandleFunc("/api/events/stream", eventsStream)
api.HandleFunc("/api/perf/stream", perfStreamHandler) api.HandleFunc("/api/perf/stream", perfStreamHandler)
api.HandleFunc("/api/status/disk", diskStatusHandler) api.HandleFunc("/api/status/disk", diskStatusHandler)
api.HandleFunc("/api/status/ai-server", aiServerStatusHandler)
api.HandleFunc("/api/status/log", appLogHandler)
api.HandleFunc("/api/autostart/state", autostartStateHandler) api.HandleFunc("/api/autostart/state", autostartStateHandler)
api.HandleFunc("/api/autostart/state/stream", autostartStateStreamHandler) api.HandleFunc("/api/autostart/state/stream", autostartStateStreamHandler)
@ -100,13 +101,13 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
dsn, err := buildPostgresDSNFromSettings() dsn, err := buildPostgresDSNFromSettings()
if err != nil { if err != nil {
fmt.Println("⚠️ models DSN:", err) appLogln("⚠️ models DSN:", err)
} }
fmt.Println("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn)) appLogln("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn))
store := NewModelStore(dsn) store := NewModelStore(dsn)
if err := store.Load(); err != nil { if err := store.Load(); err != nil {
fmt.Println("⚠️ models load:", err) appLogln("⚠️ models load:", err)
} }
setCoverModelStore(store) setCoverModelStore(store)
@ -147,14 +148,14 @@ func buildPostgresDSNFromSettings() (string, error) {
dbURL := strings.TrimSpace(s.DatabaseURL) dbURL := strings.TrimSpace(s.DatabaseURL)
if dbURL == "" { if dbURL == "" {
return "", fmt.Errorf("databaseUrl ist leer") return "", appErrorf("databaseUrl ist leer")
} }
// Wenn databaseUrl ein Passwort enthält: verwenden, // Wenn databaseUrl ein Passwort enthält: verwenden,
// aber gleichzeitig safe sein, falls da Altbestand drin ist. // aber gleichzeitig safe sein, falls da Altbestand drin ist.
u, err := url.Parse(dbURL) u, err := url.Parse(dbURL)
if err != nil { if err != nil {
return "", fmt.Errorf("databaseUrl ungültig: %w", err) return "", appErrorf("databaseUrl ungültig: %w", err)
} }
// 1) Wenn URL bereits Passwort enthält -> nur verwenden, wenn es NICHT der Placeholder ist // 1) Wenn URL bereits Passwort enthält -> nur verwenden, wenn es NICHT der Placeholder ist
@ -177,7 +178,7 @@ func buildPostgresDSNFromSettings() (string, error) {
plainPw, err := decryptSettingString(enc) plainPw, err := decryptSettingString(enc)
if err != nil { if err != nil {
return "", fmt.Errorf("db password decrypt failed: %w", err) return "", appErrorf("db password decrypt failed: %w", err)
} }
plainPw = strings.TrimSpace(plainPw) plainPw = strings.TrimSpace(plainPw)
if plainPw == "" { if plainPw == "" {
@ -190,7 +191,7 @@ func buildPostgresDSNFromSettings() (string, error) {
user = u.User.Username() user = u.User.Username()
} }
if strings.TrimSpace(user) == "" { if strings.TrimSpace(user) == "" {
return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") return "", appErrorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen")
} }
u.User = url.UserPassword(user, plainPw) u.User = url.UserPassword(user, plainPw)
@ -200,12 +201,12 @@ func buildPostgresDSNFromSettings() (string, error) {
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) { func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
dbURL := strings.TrimSpace(s.DatabaseURL) dbURL := strings.TrimSpace(s.DatabaseURL)
if dbURL == "" { if dbURL == "" {
return "", fmt.Errorf("databaseUrl ist leer") return "", appErrorf("databaseUrl ist leer")
} }
u, err := url.Parse(dbURL) u, err := url.Parse(dbURL)
if err != nil { if err != nil {
return "", fmt.Errorf("databaseUrl ungültig: %w", err) return "", appErrorf("databaseUrl ungültig: %w", err)
} }
// Wenn URL bereits ein echtes Passwort enthält, direkt verwenden // Wenn URL bereits ein echtes Passwort enthält, direkt verwenden
@ -226,7 +227,7 @@ func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
plainPw, err := decryptSettingString(enc) plainPw, err := decryptSettingString(enc)
if err != nil { if err != nil {
return "", fmt.Errorf("db password decrypt failed: %w", err) return "", appErrorf("db password decrypt failed: %w", err)
} }
plainPw = strings.TrimSpace(plainPw) plainPw = strings.TrimSpace(plainPw)
if plainPw == "" { if plainPw == "" {
@ -238,7 +239,7 @@ func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
user = u.User.Username() user = u.User.Username()
} }
if strings.TrimSpace(user) == "" { if strings.TrimSpace(user) == "" {
return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") return "", appErrorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen")
} }
u.User = url.UserPassword(user, plainPw) u.User = url.UserPassword(user, plainPw)

View File

@ -5,7 +5,6 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"math" "math"
"net/http" "net/http"
"os" "os"
@ -178,7 +177,7 @@ func maybeRemuxTSForJob(job *RecordJob, path string) (string, error) {
func moveToDoneDir(src string) (string, error) { func moveToDoneDir(src string) (string, error) {
src = strings.TrimSpace(src) src = strings.TrimSpace(src)
if src == "" { if src == "" {
return "", fmt.Errorf("src empty") return "", appErrorf("src empty")
} }
s := getSettings() s := getSettings()
@ -189,7 +188,7 @@ func moveToDoneDir(src string) (string, error) {
doneAbs = strings.TrimSpace(s.DoneDir) doneAbs = strings.TrimSpace(s.DoneDir)
} }
if strings.TrimSpace(doneAbs) == "" { if strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir empty") return "", appErrorf("doneDir empty")
} }
// Quelle normalisieren/abs machen (best effort) // Quelle normalisieren/abs machen (best effort)
@ -202,7 +201,7 @@ func moveToDoneDir(src string) (string, error) {
fi, err := os.Stat(srcAbs) fi, err := os.Stat(srcAbs)
if err != nil || fi.IsDir() { if err != nil || fi.IsDir() {
return "", fmt.Errorf("src not found: %v", err) return "", appErrorf("src not found: %v", err)
} }
file := filepath.Base(srcAbs) file := filepath.Base(srcAbs)

View File

@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@ -82,10 +83,333 @@ func buildStartupNotificationMessage() string {
return "Die App läuft im Hintergrund." return "Die App läuft im Hintergrund."
} }
type aiServerProcess struct {
cmd *exec.Cmd
}
func aiServerAutostartEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART")))
// Standard: aktiv.
if raw == "" {
return true
}
switch raw {
case "0", "false", "no", "off":
return false
default:
return true
}
}
func aiServerURL() string {
raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL"))
if raw == "" {
raw = "http://127.0.0.1:8765"
}
return strings.TrimRight(raw, "/")
}
func aiServerPortFromURL() string {
url := aiServerURL()
if strings.Contains(url, ":8765") {
return "8765"
}
// Einfacher Fallback. Wenn du später andere Ports willst,
// setze AI_SERVER_PORT explizit.
port := strings.TrimSpace(os.Getenv("AI_SERVER_PORT"))
if port != "" {
return port
}
return "8765"
}
func aiServerPythonPath() string {
raw := strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON"))
if raw != "" {
return raw
}
// Windows py launcher zuerst versuchen.
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("py"); err == nil {
return "py"
}
}
if _, err := exec.LookPath("python"); err == nil {
return "python"
}
if _, err := exec.LookPath("python3"); err == nil {
return "python3"
}
return "python"
}
func findAIServerScriptDir() (string, error) {
cwd, _ := os.Getwd()
exePath, _ := os.Executable()
exeDir := ""
if exePath != "" {
exeDir = filepath.Dir(exePath)
}
candidates := []string{
filepath.Join(cwd, "backend", "ai_server.py"),
filepath.Join(cwd, "ai_server.py"),
}
if exeDir != "" {
candidates = append(candidates,
filepath.Join(exeDir, "backend", "ai_server.py"),
filepath.Join(exeDir, "ai_server.py"),
)
}
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)
return filepath.Dir(cleanPath), nil
}
}
embeddedDir, err := embeddedAIServerDir()
if err != nil {
return "", appErrorf("embedded ai_server.py konnte nicht extrahiert werden: %w", err)
}
appLogln("✅ embedded ai_server.py extrahiert:", filepath.Join(embeddedDir, "ai_server.py"))
return embeddedDir, nil
}
func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{
Timeout: 800 * time.Millisecond,
}
for {
if time.Now().After(deadline) {
return appErrorf("AI Server nicht rechtzeitig bereit")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+"/health", nil)
if err == nil {
res, err := client.Do(req)
if err == nil {
_ = res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
return nil
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
}
func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
url := aiServerURL()
client := &http.Client{
Timeout: 900 * time.Millisecond,
}
status := map[string]any{
"ok": false,
"running": false,
"url": url,
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil)
if err != nil {
status["error"] = err.Error()
writeJSON(w, http.StatusOK, status)
return
}
res, err := client.Do(req)
if err != nil {
status["error"] = err.Error()
writeJSON(w, http.StatusOK, status)
return
}
defer res.Body.Close()
status["statusCode"] = res.StatusCode
if res.StatusCode >= 200 && res.StatusCode < 300 {
status["ok"] = true
status["running"] = true
}
writeJSON(w, http.StatusOK, status)
}
func startAIServer(ctx context.Context) (*aiServerProcess, error) {
if !aiServerAutostartEnabled() {
appLogln(" AI Server Autostart deaktiviert.")
return nil, nil
}
scriptDir, err := findAIServerScriptDir()
if err != nil {
return nil, err
}
exePath, _ := os.Executable()
exeDir := ""
if exePath != "" {
exeDir = filepath.Dir(exePath)
}
defaultProdModel := filepath.Join(exeDir, "generated", "training", "detector", "model", "best.pt")
scriptDirModel := filepath.Join(scriptDir, "generated", "training", "detector", "model", "best.pt")
pythonPath := aiServerPythonPath()
port := aiServerPortFromURL()
args := []string{}
// Windows py launcher braucht meist -3.
if runtime.GOOS == "windows" && filepath.Base(strings.ToLower(pythonPath)) == "py" {
args = append(args, "-3")
}
args = append(args,
"-m", "uvicorn",
"ai_server:app",
"--host", "127.0.0.1",
"--port", port,
"--log-level", "warning",
)
cmd := exec.CommandContext(ctx, pythonPath, args...)
cmd.Dir = scriptDir
env := os.Environ()
env = append(env,
"PYTHONUNBUFFERED=1",
"AI_SERVER_URL="+aiServerURL(),
)
// Defaults nur setzen, wenn nicht schon extern gesetzt.
if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" {
if fi, err := os.Stat(scriptDirModel); err == nil && fi != nil && !fi.IsDir() {
env = append(env, "YOLO_MODEL="+scriptDirModel)
} else if fi, err := os.Stat(defaultProdModel); err == nil && fi != nil && !fi.IsDir() {
env = append(env, "YOLO_MODEL="+defaultProdModel)
}
}
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
env = append(env, "YOLO_IMGSZ=640")
}
if strings.TrimSpace(os.Getenv("YOLO_BATCH")) == "" {
env = append(env, "YOLO_BATCH=16")
}
if strings.TrimSpace(os.Getenv("YOLO_CONF")) == "" {
env = append(env, "YOLO_CONF=0.25")
}
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
if runtime.GOOS == "windows" {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
}
if err := cmd.Start(); err != nil {
return nil, err
}
proc := &aiServerProcess{
cmd: cmd,
}
go func() {
err := cmd.Wait()
if err != nil && ctx.Err() == nil {
appLogf("⚠️ AI Server beendet: %v", err)
} else {
appLogln("AI Server beendet.")
}
}()
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := waitForAIServer(waitCtx, aiServerURL(), 30*time.Second); err != nil {
appLogln("⚠️ AI Server noch nicht bereit:", err)
// Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen.
return proc, nil
}
appLogln("🧠 AI Server bereit:", aiServerURL())
return proc, nil
}
func (p *aiServerProcess) Stop() {
if p == nil {
return
}
if p.cmd != nil && p.cmd.Process != nil {
appLogln("🛑 Beende AI Server...")
_ = p.cmd.Process.Kill()
}
}
// --- main --- // --- main ---
func main() { func main() {
clearAppLog()
initAppLog()
defer closeAppLog()
loadSettings() loadSettings()
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
// Nur eine Instanz erlauben: // Nur eine Instanz erlauben:
appLn, err := net.Listen("tcp", ":9999") appLn, err := net.Listen("tcp", ":9999")
if err != nil { if err != nil {
@ -93,14 +417,17 @@ func main() {
"Recorder", "Recorder",
"Die Anwendung wird bereits ausgeführt.", "Die Anwendung wird bereits ausgeführt.",
) )
fmt.Println("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") appLogln("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.")
os.Exit(0) os.Exit(0)
} }
defer appLn.Close() defer appLn.Close()
aiProc, err := startAIServer(appCtx)
if err != nil {
appLogln("⚠️ AI Server konnte nicht gestartet werden:", err)
}
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
// Disk-Notbremse bleibt trotzdem aktiv, weil isAutostartPaused()
// zusätzlich diskEmergency prüft.
resetAutostartPauseOnStartup() resetAutostartPauseOnStartup()
fixKeepRootFilesIntoModelSubdirs() fixKeepRootFilesIntoModelSubdirs()
@ -112,11 +439,8 @@ func main() {
go startGeneratedGarbageCollector() go startGeneratedGarbageCollector()
if err := initNSFWDetector(); err != nil { // Altes NSFW-ONNX nicht mehr hart initialisieren.
fmt.Println("❌ NSFW-ONNX Fehler:", err) // Das neue Training-/YOLO-Modell wird über trainingPredictFrame genutzt.
os.Exit(1)
}
var closeNSFWOnce sync.Once var closeNSFWOnce sync.Once
closeNSFW := func() { closeNSFW := func() {
closeNSFWOnce.Do(func() { closeNSFWOnce.Do(func() {
@ -129,7 +453,7 @@ func main() {
auth, err := NewAuthManager() auth, err := NewAuthManager()
if err != nil { if err != nil {
fmt.Println("❌ auth init:", err) appLogln("❌ auth init:", err)
closeNSFW() closeNSFW()
os.Exit(1) os.Exit(1)
} }
@ -138,7 +462,7 @@ func main() {
setChaturbateOnlineModelStore(store) setChaturbateOnlineModelStore(store)
if err := clearAllPendingAutoStartOnStartup(); err != nil { if err := clearAllPendingAutoStartOnStartup(); err != nil {
fmt.Println("⚠️ [pending-autostart] startup clear failed:", err) appLogln("⚠️ [pending-autostart] startup clear failed:", err)
} }
// Hintergrund-Worker // Hintergrund-Worker
@ -154,18 +478,18 @@ func main() {
defer cancel() defer cancel()
if _, err := refreshChaturbateSnapshotNow(ctx); err != nil { if _, err := refreshChaturbateSnapshotNow(ctx); err != nil {
fmt.Println("⚠️ Chaturbate API failed:", err) appLogln("⚠️ Chaturbate API failed:", err)
} else { } else {
fmt.Println("✅ Chaturbate API geladen") appLogln("✅ Chaturbate API geladen")
} }
}() }()
} }
if _, err := ensureCoversDir(); err != nil { if _, err := ensureCoversDir(); err != nil {
fmt.Println("⚠️ covers dir:", err) appLogln("⚠️ covers dir:", err)
} }
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999") appLogln("🌐 HTTP-API aktiv: http://localhost:9999")
handler := withCORS(mux) handler := withCORS(mux)
srv := &http.Server{ srv := &http.Server{
@ -176,12 +500,19 @@ func main() {
var shutdownOnce sync.Once var shutdownOnce sync.Once
shutdown := func() { shutdown := func() {
shutdownOnce.Do(func() { shutdownOnce.Do(func() {
fmt.Println("🛑 Beende Server...") appLogln("🛑 Shutdown gestartet")
if aiProc != nil {
aiProc.Stop()
}
appCancel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
_ = srv.Shutdown(ctx) _ = srv.Shutdown(ctx)
closeNSFW() closeNSFW()
}) })
} }
@ -214,7 +545,7 @@ func main() {
go func() { go func() {
err := <-serverErrCh err := <-serverErrCh
if err != nil { if err != nil {
fmt.Println("❌ HTTP-Server Fehler:", err) appLogln("❌ HTTP-Server Fehler:", err)
shutdown() shutdown()
} }
}() }()

View File

@ -4,7 +4,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -114,7 +113,7 @@ func getSettings() RecorderSettings {
func loadSettings() { func loadSettings() {
p := settingsFilePath() p := settingsFilePath()
b, err := os.ReadFile(p) b, err := os.ReadFile(p)
fmt.Println("🔧 settingsFile:", p) appLogln("🔧 settingsFile:", p)
if err == nil { if err == nil {
s := getSettings() // ✅ startet mit Defaults s := getSettings() // ✅ startet mit Defaults
if json.Unmarshal(b, &s) == nil { if json.Unmarshal(b, &s) == nil {
@ -200,10 +199,10 @@ func loadSettings() {
// ffmpeg-Pfad anhand Settings/Env/PATH bestimmen // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen
ffmpegPath = detectFFmpegPath() ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath:", ffmpegPath) appLogln("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath() ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath) appLogln("🔍 ffprobePath:", ffprobePath)
} }
@ -212,18 +211,18 @@ func saveSettingsToDisk() {
b, err := json.MarshalIndent(s, "", " ") b, err := json.MarshalIndent(s, "", " ")
if err != nil { if err != nil {
fmt.Println("⚠️ settings marshal:", err) appLogln("⚠️ settings marshal:", err)
return return
} }
b = append(b, '\n') b = append(b, '\n')
p := settingsFilePath() p := settingsFilePath()
if err := atomicWriteFile(p, b); err != nil { if err := atomicWriteFile(p, b); err != nil {
fmt.Println("⚠️ settings write:", err) appLogln("⚠️ settings write:", err)
return return
} }
// optional // optional
// fmt.Println("✅ settings saved:", p) // appLogln("✅ settings saved:", p)
} }
type RecorderSettingsPublic struct { type RecorderSettingsPublic struct {
@ -505,10 +504,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
ffmpegPath = detectFFmpegPath() ffmpegPath = detectFFmpegPath()
} }
//fmt.Println("🔍 ffmpegPath:", ffmpegPath) //appLogln("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath() ffprobePath = detectFFprobePath()
//fmt.Println("🔍 ffprobePath:", ffprobePath) //appLogln("🔍 ffprobePath:", ffprobePath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")

View File

@ -378,10 +378,10 @@ type normalizedSegment struct {
func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) { func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) {
if duration <= 0 { if duration <= 0 {
return nil, fmt.Errorf("duration <= 0") return nil, appErrorf("duration <= 0")
} }
if len(raw) == 0 { if len(raw) == 0 {
return nil, fmt.Errorf("keine segmente übergeben") return nil, appErrorf("keine segmente übergeben")
} }
out := make([]normalizedSegment, 0, len(raw)) out := make([]normalizedSegment, 0, len(raw))
@ -415,7 +415,7 @@ func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalize
} }
if len(out) == 0 { if len(out) == 0 {
return nil, fmt.Errorf("keine gültigen segmente übrig") return nil, appErrorf("keine gültigen segmente übrig")
} }
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
@ -445,7 +445,7 @@ func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalize
} }
if len(dedup) == 0 { if len(dedup) == 0 {
return nil, fmt.Errorf("keine eindeutigen segmente übrig") return nil, appErrorf("keine eindeutigen segmente übrig")
} }
return dedup, nil return dedup, nil
@ -474,7 +474,7 @@ func uniqueSplitOutPath(dir, fileName string) string {
func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
if duration <= 0 { if duration <= 0 {
return nil, fmt.Errorf("duration <= 0") return nil, appErrorf("duration <= 0")
} }
out := make([]float64, 0, len(raw)) out := make([]float64, 0, len(raw))
@ -489,7 +489,7 @@ func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
} }
if len(out) == 0 { if len(out) == 0 {
return nil, fmt.Errorf("alle split-punkte liegen außerhalb der videodauer") return nil, appErrorf("alle split-punkte liegen außerhalb der videodauer")
} }
sort.Float64s(out) sort.Float64s(out)
@ -502,7 +502,7 @@ func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
} }
if len(dedup) == 0 { if len(dedup) == 0 {
return nil, fmt.Errorf("keine eindeutigen split-punkte übrig") return nil, appErrorf("keine eindeutigen split-punkte übrig")
} }
return dedup, nil return dedup, nil
@ -555,13 +555,13 @@ func splitSingleSegment(
onProgress func(ratio float64), onProgress func(ratio float64),
) error { ) error {
if strings.TrimSpace(srcPath) == "" { if strings.TrimSpace(srcPath) == "" {
return fmt.Errorf("srcPath leer") return appErrorf("srcPath leer")
} }
if strings.TrimSpace(outPath) == "" { if strings.TrimSpace(outPath) == "" {
return fmt.Errorf("outPath leer") return appErrorf("outPath leer")
} }
if durSec <= 0 { if durSec <= 0 {
return fmt.Errorf("dauer <= 0") return appErrorf("dauer <= 0")
} }
ext := strings.ToLower(filepath.Ext(outPath)) ext := strings.ToLower(filepath.Ext(outPath))
@ -682,12 +682,12 @@ func splitSingleSegment(
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
if ctx.Err() != nil { if ctx.Err() != nil {
return fmt.Errorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err()) return appErrorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err())
} }
msg := strings.TrimSpace(stderr.String()) msg := strings.TrimSpace(stderr.String())
if msg != "" { if msg != "" {
return fmt.Errorf("%w (%s)", err, msg) return appErrorf("%w (%s)", err, msg)
} }
return err return err
} }
@ -695,12 +695,12 @@ func splitSingleSegment(
fi, err := os.Stat(tmpPath) fi, err := os.Stat(tmpPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return fmt.Errorf("ffmpeg hat keine gültige datei erzeugt") return appErrorf("ffmpeg hat keine gültige datei erzeugt")
} }
if err := os.Rename(tmpPath, outPath); err != nil { if err := os.Rename(tmpPath, outPath); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return fmt.Errorf("rename fehlgeschlagen: %w", err) return appErrorf("rename fehlgeschlagen: %w", err)
} }
if onProgress != nil { if onProgress != nil {

View File

@ -117,7 +117,7 @@ func publishAnalysisProgress(ev analysisProgressEvent) {
publishSSE("analysisProgress", b) publishSSE("analysisProgress", b)
} }
func publishAnalysisStarted(total int, message string) int64 { func publishAnalysisStarted(file string, total int, message string) int64 {
startedAtMs := time.Now().UnixMilli() startedAtMs := time.Now().UnixMilli()
publishAnalysisProgress(analysisProgressEvent{ publishAnalysisProgress(analysisProgressEvent{
@ -126,6 +126,7 @@ func publishAnalysisStarted(total int, message string) int64 {
Progress: 0, Progress: 0,
Current: 0, Current: 0,
Total: total, Total: total,
File: strings.TrimSpace(file),
Message: message, Message: message,
StartedAtMs: startedAtMs, StartedAtMs: startedAtMs,
}) })
@ -145,13 +146,13 @@ func publishAnalysisStep(startedAtMs int64, current int, total int, file string,
Progress: progress, Progress: progress,
Current: current, Current: current,
Total: total, Total: total,
File: file, File: strings.TrimSpace(file),
Message: message, Message: message,
StartedAtMs: startedAtMs, StartedAtMs: startedAtMs,
}) })
} }
func publishAnalysisFinished(startedAtMs int64, total int, message string) { func publishAnalysisFinished(startedAtMs int64, total int, file string, message string) {
finishedAtMs := time.Now().UnixMilli() finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs durationMs := finishedAtMs - startedAtMs
if durationMs < 0 { if durationMs < 0 {
@ -164,6 +165,7 @@ func publishAnalysisFinished(startedAtMs int64, total int, message string) {
Progress: 1, Progress: 1,
Current: total, Current: total,
Total: total, Total: total,
File: strings.TrimSpace(file),
Message: message, Message: message,
StartedAtMs: startedAtMs, StartedAtMs: startedAtMs,
FinishedAtMs: finishedAtMs, FinishedAtMs: finishedAtMs,
@ -171,7 +173,7 @@ func publishAnalysisFinished(startedAtMs int64, total int, message string) {
}) })
} }
func publishAnalysisError(startedAtMs int64, message string, err error) { func publishAnalysisError(startedAtMs int64, file string, message string, err error) {
finishedAtMs := time.Now().UnixMilli() finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs durationMs := finishedAtMs - startedAtMs
if durationMs < 0 { if durationMs < 0 {
@ -187,6 +189,7 @@ func publishAnalysisError(startedAtMs int64, message string, err error) {
Running: false, Running: false,
Phase: "error", Phase: "error",
Progress: 0, Progress: 0,
File: strings.TrimSpace(file),
Message: message, Message: message,
Error: errText, Error: errText,
StartedAtMs: startedAtMs, StartedAtMs: startedAtMs,

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -462,9 +461,10 @@ func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth {
truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.TeaserReady = fileExistsNonEmpty(previewPath)
truth.SpritesReady = fileExistsNonEmpty(spritePath) truth.SpritesReady = fileExistsNonEmpty(spritePath)
// Analyse bleibt inhaltlich/meta-basiert // Analyse gilt als fertig, wenn alle Standard-Ziele gespeichert sind.
// Auch "keine Treffer" zählt als fertig.
if strings.TrimSpace(videoPath) != "" { if strings.TrimSpace(videoPath) != "" {
truth.AnalyzeReady = hasAIResultsForOutput(videoPath) truth.AnalyzeReady = hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
} }
return truth return truth
@ -551,7 +551,13 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
doneAbs, err := resolvePathRelativeToApp(settings.DoneDir) doneAbs, err := resolvePathRelativeToApp(settings.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) if err == nil {
err = appErrorf("doneDir auflösung fehlgeschlagen: doneDir ist leer")
} else {
err = appErrorf("doneDir auflösung fehlgeschlagen: %w", err)
}
finishWithErr(err)
return return
} }
@ -759,7 +765,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
}) })
finishFileControl() finishFileControl()
fmt.Println("⚠️ assetPathsForID:", perr) appLogln("⚠️ assetPathsForID:", perr)
continue continue
} }

View File

@ -243,7 +243,7 @@ func finishCheckVideosTask(jobID string, err error) {
func runCheckVideosTask(jobID string, ctx context.Context) { func runCheckVideosTask(jobID string, ctx context.Context) {
if err := acquireExclusiveTask(ctx); err != nil { if err := acquireExclusiveTask(ctx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen")) finishCheckVideosTask(jobID, appErrorf("abgebrochen"))
} else { } else {
finishCheckVideosTask(jobID, err) finishCheckVideosTask(jobID, err)
} }
@ -271,7 +271,7 @@ func runCheckVideosTask(jobID string, ctx context.Context) {
for i, file := range files { for i, file := range files {
select { select {
case <-ctx.Done(): case <-ctx.Done():
finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen")) finishCheckVideosTask(jobID, appErrorf("abgebrochen"))
return return
default: default:
} }
@ -369,7 +369,7 @@ func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLu
w := b.Dx() w := b.Dx()
h := b.Dy() h := b.Dy()
if w <= 0 || h <= 0 { if w <= 0 || h <= 0 {
return 0, 0, fmt.Errorf("invalid image bounds") return 0, 0, appErrorf("invalid image bounds")
} }
totalPixels := w * h totalPixels := w * h
@ -403,7 +403,7 @@ func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLu
} }
if count == 0 { if count == 0 {
return 0, 0, fmt.Errorf("no sampled pixels") return 0, 0, appErrorf("no sampled pixels")
} }
avgLuma = sumLuma / float64(count) avgLuma = sumLuma / float64(count)
@ -421,10 +421,10 @@ func analyzeJPGBlackness(jpg []byte) (avgLuma float64, blackRatio float64, err e
func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) { func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) {
if cols <= 0 || rows <= 0 { if cols <= 0 || rows <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite layout") return image.Rectangle{}, appErrorf("invalid sprite layout")
} }
if index < 0 || index >= cols*rows { if index < 0 || index >= cols*rows {
return image.Rectangle{}, fmt.Errorf("sprite index out of range") return image.Rectangle{}, appErrorf("sprite index out of range")
} }
b := img.Bounds() b := img.Bounds()
@ -438,7 +438,7 @@ func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle,
rect := image.Rect(x0, y0, x1, y1) rect := image.Rect(x0, y0, x1, y1)
if rect.Dx() <= 0 || rect.Dy() <= 0 { if rect.Dx() <= 0 || rect.Dy() <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite cell bounds") return image.Rectangle{}, appErrorf("invalid sprite cell bounds")
} }
return rect, nil return rect, nil
@ -519,7 +519,7 @@ func uniqueTailTimes(times []float64, dur float64) []float64 {
func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
assetID := assetIDFromVideoPath(videoPath) assetID := assetIDFromVideoPath(videoPath)
if assetID == "" { if assetID == "" {
return nil, fmt.Errorf("asset id fehlt") return nil, appErrorf("asset id fehlt")
} }
_, _, _, spritePath, metaPath, err := assetPathsForID(assetID) _, _, _, spritePath, metaPath, err := assetPathsForID(assetID)
@ -529,13 +529,13 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
f, err := os.Open(spritePath) f, err := os.Open(spritePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("sprite open failed: %w", err) return nil, appErrorf("sprite open failed: %w", err)
} }
defer f.Close() defer f.Close()
img, err := jpeg.Decode(f) img, err := jpeg.Decode(f)
if err != nil { if err != nil {
return nil, fmt.Errorf("sprite decode failed: %w", err) return nil, appErrorf("sprite decode failed: %w", err)
} }
cols, rows, count, _, _ := fixedPreviewSpriteLayout() cols, rows, count, _, _ := fixedPreviewSpriteLayout()
@ -558,7 +558,7 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
maxCells := cols * rows maxCells := cols * rows
if maxCells <= 0 { if maxCells <= 0 {
return nil, fmt.Errorf("invalid sprite layout") return nil, appErrorf("invalid sprite layout")
} }
if count <= 0 { if count <= 0 {
count = maxCells count = maxCells
@ -567,7 +567,7 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
count = maxCells count = maxCells
} }
if count < 2 { if count < 2 {
return nil, fmt.Errorf("sprite enthält zu wenige frames") return nil, appErrorf("sprite enthält zu wenige frames")
} }
res := &tailBlackoutResult{ res := &tailBlackoutResult{
@ -641,7 +641,7 @@ func persistTailBlackoutResult(videoPath string, res *tailBlackoutResult) error
assetID := assetIDFromVideoPath(videoPath) assetID := assetIDFromVideoPath(videoPath)
if assetID == "" { if assetID == "" {
return fmt.Errorf("asset id fehlt") return appErrorf("asset id fehlt")
} }
_, _, _, _, metaPath, err := assetPathsForID(assetID) _, _, _, _, metaPath, err := assetPathsForID(assetID)

View File

@ -493,7 +493,7 @@ func removeDirIfEmpty(dir string) (bool, error) {
func triggerGeneratedGarbageCollectorSync() generatedGCStats { func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// nur 1 GC gleichzeitig // nur 1 GC gleichzeitig
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) { if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
fmt.Println("🧹 [gc] skip: already running") appLogln("🧹 [gc] skip: already running")
return generatedGCStats{} return generatedGCStats{}
} }
defer atomic.StoreInt32(&generatedGCRunning, 0) defer atomic.StoreInt32(&generatedGCRunning, 0)
@ -580,7 +580,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
fmt.Println("🧹 [gc] resolve doneDir failed:", err) appLogln("🧹 [gc] resolve doneDir failed:", err)
return stats return stats
} }
doneAbs = strings.TrimSpace(doneAbs) doneAbs = strings.TrimSpace(doneAbs)

View File

@ -85,9 +85,13 @@ type regeneratePhaseTruth struct {
AnalyzeReady bool AnalyzeReady bool
} }
func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePhaseTruth { func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string) regeneratePhaseTruth {
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
assetID = strings.TrimSpace(assetID) assetID = strings.TrimSpace(assetID)
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
}
if assetID == "" && videoPath != "" { if assetID == "" && videoPath != "" {
assetID = assetIDFromVideoPath(videoPath) assetID = assetIDFromVideoPath(videoPath)
@ -120,15 +124,15 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePh
} }
} }
// KI nur dann fertig, wenn Sprite existiert UND AI in meta.json vorhanden ist // KI nur dann fertig, wenn Sprite existiert UND passende AI in meta.json vorhanden ist
out.AnalyzeReady = out.SpritesReady && hasAIResultsForOutput(videoPath) out.AnalyzeReady = out.SpritesReady && hasAIAnalysisForOutputGoal(videoPath, goal)
} }
return out return out
} }
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error { func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID) truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
if !truth.MetaReady { if !truth.MetaReady {
return errors.New("meta.json fehlt oder ist noch unvollständig") return errors.New("meta.json fehlt oder ist noch unvollständig")
@ -143,7 +147,7 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e
} }
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error { func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID) truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
if !truth.SpritesReady { if !truth.SpritesReady {
return errors.New("preview-sprite.jpg fehlt oder ist leer") return errors.New("preview-sprite.jpg fehlt oder ist leer")
@ -329,6 +333,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
videoPath := job.VideoPath videoPath := job.VideoPath
id := job.AssetID id := job.AssetID
ctx := job.Ctx ctx := job.Ctx
requiredGoals := requiredAnalyzeGoals()
primaryGoal := "nsfw"
setRegenerateAssetsTaskRunning(file, file) setRegenerateAssetsTaskRunning(file, file)
cancelDeferredEnrichForFile(file) cancelDeferredEnrichForFile(file)
@ -390,7 +396,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady { if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).MetaReady {
failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig") failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig")
return return
} }
@ -409,7 +415,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
if !regeneratePhaseTruthForVideo(videoPath, id).ThumbReady { if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).ThumbReady {
failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer") failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer")
return return
} }
@ -428,7 +434,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
if !regeneratePhaseTruthForVideo(videoPath, id).TeaserReady { if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).TeaserReady {
failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer") failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer")
return return
} }
@ -447,7 +453,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
if !regeneratePhaseTruthForVideo(videoPath, id).SpritesReady { if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).SpritesReady {
failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer") failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer")
return return
} }
@ -457,7 +463,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
// 5) Analyse // 5) Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { if _, err := ensureAnalyzeAllGoalsForVideoCtxForce(ctx, videoPath, "", true); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob() cancelJob()
return return
@ -466,7 +472,15 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady { allAnalyzeReady := true
for _, goal := range requiredGoals {
if !hasAIAnalysisForOutputGoal(videoPath, goal) {
allAnalyzeReady = false
break
}
}
if !allAnalyzeReady {
failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert") failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert")
return return
} }

View File

@ -47,12 +47,26 @@ type RegenerateAssetsTaskState struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type GenerateAssetsLiveStatus struct {
Running bool `json:"running"`
Queued bool `json:"queued"`
CurrentFile string `json:"currentFile,omitempty"`
CurrentQueue string `json:"currentQueue,omitempty"`
CurrentPhase string `json:"currentPhase,omitempty"`
CurrentLabel string `json:"currentLabel,omitempty"`
Progress float64 `json:"progress,omitempty"`
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
TS int64 `json:"ts,omitempty"`
}
type TasksStatusResponse struct { type TasksStatusResponse struct {
GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"` GenerateAssets GenerateAssetsLiveStatus `json:"generateAssets"`
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"`
CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"` RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
CleanupJobs []CleanupTaskState `json:"cleanupJobs"` CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"`
AnyRunning bool `json:"anyRunning"` CleanupJobs []CleanupTaskState `json:"cleanupJobs"`
AnyRunning bool `json:"anyRunning"`
} }
var ( var (
@ -86,6 +100,268 @@ func anyToString(v any) string {
} }
} }
func anyToBool(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
s := strings.TrimSpace(strings.ToLower(x))
return s == "1" || s == "true" || s == "yes" || s == "running" || s == "queued"
default:
return false
}
}
func anyToInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
default:
return 0
}
}
func anyToFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
n, _ := x.Float64()
return n
default:
return 0
}
}
func anyToMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return nil
}
return out
}
func taskFirstString(m map[string]any, keys ...string) string {
for _, key := range keys {
if s := strings.TrimSpace(anyToString(m[key])); s != "" {
return s
}
}
return ""
}
func firstFloat(m map[string]any, keys ...string) float64 {
for _, key := range keys {
if v, ok := m[key]; ok {
f := anyToFloat64(v)
if f != 0 {
return f
}
}
}
return 0
}
func normalizeTaskProgress(v float64) float64 {
if v > 1 && v <= 100 {
v = v / 100
}
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func taskStepLabel(queue, phase string) string {
queue = strings.TrimSpace(strings.ToLower(queue))
phase = strings.TrimSpace(strings.ToLower(phase))
switch queue + ":" + phase {
case "postwork:meta":
return "Meta"
case "postwork:thumb":
return "Vorschaubild"
case "postwork:teaser", "postwork:assets":
return "Teaser"
case "postwork:sprites":
return "Sprites"
case "enrich:analyze", "enrich:assets", "enrich:":
return "Analyse"
default:
if queue == "enrich" {
return "Analyse"
}
if phase != "" {
return phase
}
return "Meta"
}
}
func taskLabelWithProgress(queue, phase, label string, progress float64) string {
queue = strings.TrimSpace(strings.ToLower(queue))
phase = strings.TrimSpace(strings.ToLower(phase))
label = strings.TrimSpace(label)
progress = normalizeTaskProgress(progress)
isAnalyze := queue == "enrich" && (phase == "" || phase == "analyze" || phase == "assets")
if isAnalyze && progress > 0 {
return fmt.Sprintf("Analyse %d%%", int(progress*100+0.5))
}
if label != "" {
return label
}
return taskStepLabel(queue, phase)
}
func liveStatusFromJobMap(m map[string]any) GenerateAssetsLiveStatus {
if m == nil {
return GenerateAssetsLiveStatus{}
}
state := strings.TrimSpace(strings.ToLower(taskFirstString(m, "state", "status")))
running := anyToBool(m["running"]) || state == "running"
queued := anyToBool(m["queued"]) || state == "queued"
if !running && !queued {
return GenerateAssetsLiveStatus{}
}
file := taskFirstString(m, "currentFile", "file")
queue := strings.TrimSpace(strings.ToLower(taskFirstString(m, "currentQueue", "queue")))
phase := strings.TrimSpace(strings.ToLower(taskFirstString(m, "currentPhase", "phase")))
label := taskFirstString(m, "currentLabel", "label", "text", "message")
current := anyToInt(m["current"])
total := anyToInt(m["total"])
progress := firstFloat(m, "progress", "overallProgress", "overall")
progress = normalizeTaskProgress(progress)
if progress == 0 && total > 0 && current > 0 {
progress = normalizeTaskProgress(float64(current) / float64(total))
}
if queue == "" {
queue = "postwork"
}
if queue == "enrich" && (phase == "" || phase == "assets") {
phase = "analyze"
}
return GenerateAssetsLiveStatus{
Running: running,
Queued: queued,
CurrentFile: file,
CurrentQueue: queue,
CurrentPhase: phase,
CurrentLabel: taskLabelWithProgress(queue, phase, label, progress),
Progress: progress,
Current: current,
Total: total,
TS: time.Now().UnixMilli(),
}
}
func liveStatusFromRegenerateJob(job map[string]any) GenerateAssetsLiveStatus {
if job == nil {
return GenerateAssetsLiveStatus{}
}
file := taskFirstString(job, "currentFile", "file")
assetID := taskFirstString(job, "assetId")
rawPhases, _ := job["phases"].([]any)
for _, raw := range rawPhases {
p := anyToMap(raw)
if p == nil {
continue
}
state := strings.TrimSpace(strings.ToLower(taskFirstString(p, "state", "status")))
if state != "running" && state != "queued" {
continue
}
if file != "" {
p["file"] = file
}
if assetID != "" {
p["assetId"] = assetID
}
return liveStatusFromJobMap(p)
}
return liveStatusFromJobMap(job)
}
func buildGenerateAssetsLiveStatus(
assetsJobs []AssetsTaskState,
regenerateJobs []map[string]any,
) GenerateAssetsLiveStatus {
// Running gewinnt vor queued.
for _, st := range assetsJobs {
status := liveStatusFromJobMap(anyToMap(st))
if status.Running {
return status
}
}
for _, job := range regenerateJobs {
status := liveStatusFromRegenerateJob(job)
if status.Running {
return status
}
}
for _, st := range assetsJobs {
status := liveStatusFromJobMap(anyToMap(st))
if status.Queued {
return status
}
}
for _, job := range regenerateJobs {
status := liveStatusFromRegenerateJob(job)
if status.Queued {
return status
}
}
return GenerateAssetsLiveStatus{}
}
func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState {
regenerateAssetsTaskMu.Lock() regenerateAssetsTaskMu.Lock()
st := regenerateAssetsTaskState st := regenerateAssetsTaskState
@ -261,6 +537,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
regenerateJobs := getRegenerateAssetsJobsSnapshot() regenerateJobs := getRegenerateAssetsJobsSnapshot()
checkVideosJobs := snapshotCheckVideosJobs() checkVideosJobs := snapshotCheckVideosJobs()
cleanupJobs := snapshotCleanupJobs() cleanupJobs := snapshotCleanupJobs()
generateAssets := buildGenerateAssetsLiveStatus(assetsJobs, regenerateJobs)
anyRunning := false anyRunning := false
@ -302,6 +579,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
} }
resp := TasksStatusResponse{ resp := TasksStatusResponse{
GenerateAssets: generateAssets,
GenerateAssetsJobs: assetsJobs, GenerateAssetsJobs: assetsJobs,
RegenerateAssetsJobs: regenerateJobs, RegenerateAssetsJobs: regenerateJobs,
CheckVideosJobs: checkVideosJobs, CheckVideosJobs: checkVideosJobs,

View File

@ -159,7 +159,7 @@ func generateTeaserChunkMP4(
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) return appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
} }
_ = os.Remove(out) _ = os.Remove(out)
return os.Rename(tmp, out) return os.Rename(tmp, out)
@ -377,7 +377,7 @@ func generateTeaserPreviewMP4WithProgress(
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) return appErrorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
} }
_ = os.Remove(outPath) _ = os.Remove(outPath)
@ -461,7 +461,7 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) return appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
} }
_ = os.Remove(outPath) _ = os.Remove(outPath)

View File

@ -4,6 +4,7 @@ package main
import ( import (
"bufio" "bufio"
"context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -394,19 +395,19 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
var startedAtMs int64 var startedAtMs int64
if refreshPrediction { if refreshPrediction {
startedAtMs = publishAnalysisStarted(2, "Aktuelles Bild wird neu analysiert…") startedAtMs = publishAnalysisStarted("", 2, "Aktuelles Bild wird neu analysiert…")
} }
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil { if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil {
if refreshPrediction { if refreshPrediction {
publishAnalysisError(startedAtMs, "Aktuelles Bild konnte nicht neu analysiert werden.", err) publishAnalysisError(startedAtMs, "", "Aktuelles Bild konnte nicht neu analysiert werden.", err)
} }
trainingWriteError(w, http.StatusInternalServerError, err.Error()) trainingWriteError(w, http.StatusInternalServerError, err.Error())
return return
} else if ok { } else if ok {
if refreshPrediction { if refreshPrediction {
publishAnalysisFinished(startedAtMs, 2, "Analyse abgeschlossen.") publishAnalysisFinished(startedAtMs, 2, sample.SourceFile, "Analyse abgeschlossen.")
} }
trainingWriteJSON(w, http.StatusOK, sample) trainingWriteJSON(w, http.StatusOK, sample)
@ -414,16 +415,16 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
startedAtMs := publishAnalysisStarted(4, "Neues Trainingsbild wird vorbereitet…") startedAtMs := publishAnalysisStarted("", 4, "Neues Trainingsbild wird vorbereitet…")
sample, err := trainingCreateNextSampleWithProgress(startedAtMs) sample, err := trainingCreateNextSampleWithProgress(startedAtMs)
if err != nil { if err != nil {
publishAnalysisError(startedAtMs, "Trainingsbild konnte nicht erstellt werden.", err) publishAnalysisError(startedAtMs, "", "Trainingsbild konnte nicht erstellt werden.", err)
trainingWriteError(w, http.StatusInternalServerError, err.Error()) trainingWriteError(w, http.StatusInternalServerError, err.Error())
return return
} }
publishAnalysisFinished(startedAtMs, 4, "Analyse abgeschlossen.") publishAnalysisFinished(startedAtMs, 4, sample.SourceFile, "Analyse abgeschlossen.")
trainingWriteJSON(w, http.StatusOK, sample) trainingWriteJSON(w, http.StatusOK, sample)
} }
@ -494,6 +495,35 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i
continue continue
} }
if refreshPrediction {
sourceFile := strings.TrimSpace(sample.SourceFile)
if sourceFile == "" {
sourceFile = filepath.Base(sample.SourcePath)
}
publishAnalysisStep(
startedAtMs,
1,
2,
sourceFile,
"Aktuelles Bild wird analysiert…",
)
sample.Prediction = trainingPredictFrame(framePath)
publishAnalysisStep(
startedAtMs,
2,
2,
sourceFile,
"Analyse-Ergebnis wird gespeichert…",
)
if err := trainingWriteSample(root, sample); err != nil {
return nil, false, err
}
}
return sample, true, nil return sample, true, nil
} }
@ -627,7 +657,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
if len(detectorBoxes) > 0 { if len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil {
fmt.Println("⚠️ detector sample write failed:", err) appLogln("⚠️ detector sample write failed:", err)
} }
} }
@ -703,7 +733,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
// ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später // ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später
// einen echten 80/20 Split verwenden. // einen echten 80/20 Split verwenden.
if err := trainingEnsureDetectorValidationSample(root); err != nil { if err := trainingEnsureDetectorValidationSample(root); err != nil {
fmt.Println("⚠️ detector val sample ensure failed:", err) appLogln("⚠️ detector val sample ensure failed:", err)
} }
feedbackPath := filepath.Join(root, "feedback.jsonl") feedbackPath := filepath.Join(root, "feedback.jsonl")
@ -799,15 +829,15 @@ func trainingRunJob(root string, count int) {
if sceneErr != nil { if sceneErr != nil {
sceneStatus = "failed" sceneStatus = "failed"
fmt.Println("⚠️ scene position training failed:", sceneErr) appLogln("⚠️ scene position training failed:", sceneErr)
if sceneOutputClean != "" { if sceneOutputClean != "" {
fmt.Println("⚠️ scene position output:", sceneOutputClean) appLogln("⚠️ scene position output:", sceneOutputClean)
} }
} else { } else {
sceneStatus = "trained" sceneStatus = "trained"
if sceneOutputClean != "" { if sceneOutputClean != "" {
fmt.Println("✅ scene position training:", sceneOutputClean) appLogln("✅ scene position training:", sceneOutputClean)
} }
} }
@ -826,7 +856,7 @@ func trainingRunJob(root string, count int) {
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
if err := trainingEnsureDetectorValidationSample(root); err != nil { if err := trainingEnsureDetectorValidationSample(root); err != nil {
fmt.Println("⚠️ detector val sample ensure failed:", err) appLogln("⚠️ detector val sample ensure failed:", err)
} }
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
@ -860,7 +890,7 @@ func trainingRunJob(root string, count int) {
) )
}, },
"--root", root, "--root", root,
"--base", "yolo11n.pt", "--base", "yolo26n.pt",
"--epochs", strconv.Itoa(trainingDetectorEpochs()), "--epochs", strconv.Itoa(trainingDetectorEpochs()),
"--imgsz", "640", "--imgsz", "640",
) )
@ -871,15 +901,15 @@ func trainingRunJob(root string, count int) {
if detectorErr != nil { if detectorErr != nil {
detectorStatus = "failed" detectorStatus = "failed"
fmt.Println("⚠️ detector training failed:", detectorErr) appLogln("⚠️ detector training failed:", detectorErr)
if detectorOutputClean != "" { if detectorOutputClean != "" {
fmt.Println("⚠️ detector output:", detectorOutputClean) appLogln("⚠️ detector output:", detectorOutputClean)
} }
} else { } else {
detectorStatus = "trained" detectorStatus = "trained"
if detectorOutputClean != "" { if detectorOutputClean != "" {
fmt.Println("✅ detector training:", detectorOutputClean) appLogln("✅ detector training:", detectorOutputClean)
} }
} }
} else { } else {
@ -892,7 +922,7 @@ func trainingRunJob(root string, count int) {
minDetectorValCount, minDetectorValCount,
) )
fmt.Println("⚠️", detectorOutput) appLogln("⚠️", detectorOutput)
} }
detectorOutputClean := cleanOutput(detectorOutput) detectorOutputClean := cleanOutput(detectorOutput)
@ -998,7 +1028,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
} }
if err := trainingEnsureDetectorValidationSample(root); err != nil { if err := trainingEnsureDetectorValidationSample(root); err != nil {
fmt.Println("⚠️ detector val sample ensure failed:", err) appLogln("⚠️ detector val sample ensure failed:", err)
} }
} }
@ -1735,7 +1765,7 @@ func trainingCreateNextSample() (*TrainingSample, error) {
framePath = filepath.Join(root, "frames", id+".jpg") framePath = filepath.Join(root, "frames", id+".jpg")
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2) return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
} }
} }
@ -1809,7 +1839,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e
framePath = filepath.Join(root, "frames", id+".jpg") framePath = filepath.Join(root, "frames", id+".jpg")
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2) return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
} }
} }
@ -1914,7 +1944,7 @@ func trainingExtractFrame(videoPath string, framePath string, second float64) er
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) return appErrorf("%w: %s", err, strings.TrimSpace(string(out)))
} }
if st, err := os.Stat(framePath); err != nil || st.Size() == 0 { if st, err := os.Stat(framePath); err != nil || st.Size() == 0 {
@ -1932,7 +1962,7 @@ func trainingPredictFrame(framePath string) TrainingPrediction {
root, err := trainingRootDir() root, err := trainingRootDir()
if err != nil { if err != nil {
fmt.Println("⚠️ training predict root error:", err) appLogln("⚠️ training predict root error:", err)
return trainingEmptyPrediction("root_error") return trainingEmptyPrediction("root_error")
} }
@ -1947,6 +1977,172 @@ func trainingPredictFrame(framePath string) TrainingPrediction {
return pred return pred
} }
func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction {
settings := getSettings()
if !settings.TrainingRecognitionEnabled {
return trainingEmptyPrediction("recognition_disabled")
}
root, err := trainingRootDir()
if err != nil {
appLogln("⚠️ training predict root error:", err)
return trainingEmptyPrediction("root_error")
}
det := trainingPredictDetector(root, framePath)
return trainingPredictionFromDetector(det)
}
type TrainingSpriteBatchCandidate struct {
Index int `json:"index"`
Time float64 `json:"time"`
}
type TrainingSpriteBatchRequest struct {
SpritePath string `json:"spritePath"`
Cols int `json:"cols"`
Rows int `json:"rows"`
Count int `json:"count"`
Candidates []TrainingSpriteBatchCandidate `json:"candidates"`
}
type TrainingSpriteBatchResult struct {
Index int `json:"index"`
Time float64 `json:"time"`
Prediction TrainingPrediction `json:"prediction"`
Error string `json:"error,omitempty"`
}
type TrainingSpriteBatchResponse struct {
OK bool `json:"ok"`
Results []TrainingSpriteBatchResult `json:"results"`
Error string `json:"error,omitempty"`
}
func trainingPredictSpriteBatch(
ctx context.Context,
spritePath string,
ps previewSpriteMetaFileInfo,
candidates []spriteFrameCandidate,
) ([]TrainingSpriteBatchResult, error) {
spritePath = strings.TrimSpace(spritePath)
if spritePath == "" {
return nil, appErrorf("spritePath fehlt")
}
if !trainingRecognitionEnabled() {
out := make([]TrainingSpriteBatchResult, 0, len(candidates))
for _, c := range candidates {
out = append(out, TrainingSpriteBatchResult{
Index: c.Index,
Time: c.Time,
Prediction: trainingEmptyPrediction("recognition_disabled"),
})
}
return out, nil
}
if ps.Cols <= 0 || ps.Rows <= 0 || ps.Count <= 0 {
return nil, appErrorf("ungültige sprite meta")
}
reqCandidates := make([]TrainingSpriteBatchCandidate, 0, len(candidates))
for _, c := range candidates {
if c.Index < 0 || c.Index >= ps.Count {
continue
}
reqCandidates = append(reqCandidates, TrainingSpriteBatchCandidate{
Index: c.Index,
Time: c.Time,
})
}
if len(reqCandidates) == 0 {
return []TrainingSpriteBatchResult{}, nil
}
tmp, err := os.CreateTemp("", "sprite-batch-request-*.json")
if err != nil {
return nil, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
req := TrainingSpriteBatchRequest{
SpritePath: spritePath,
Cols: ps.Cols,
Rows: ps.Rows,
Count: ps.Count,
Candidates: reqCandidates,
}
enc := json.NewEncoder(tmp)
if err := enc.Encode(req); err != nil {
_ = tmp.Close()
return nil, err
}
if err := tmp.Close(); err != nil {
return nil, err
}
python := trainingPythonExe()
script := trainingScriptPath("predict_sprite_batch.py")
cmd := exec.CommandContext(
ctx,
python,
script,
"--input",
tmpPath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
var stdout strings.Builder
var stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
outText := strings.TrimSpace(stdout.String())
errText := strings.TrimSpace(stderr.String())
if errText != "" {
appLogln("🔎 sprite batch stderr:", errText)
}
if err != nil {
return nil, appErrorf("sprite batch predict failed: %w: %s", err, errText)
}
if outText == "" {
return nil, appErrorf("sprite batch predict returned empty stdout")
}
var resp TrainingSpriteBatchResponse
if err := json.Unmarshal([]byte(outText), &resp); err != nil {
return nil, appErrorf("sprite batch json failed: %w: %s", err, outText)
}
if !resp.OK {
if strings.TrimSpace(resp.Error) != "" {
return nil, appErrorf("%s", resp.Error)
}
return nil, appErrorf("sprite batch predict failed")
}
if resp.Results == nil {
resp.Results = []TrainingSpriteBatchResult{}
}
return resp.Results, nil
}
func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction { func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction {
python := trainingPythonExe() python := trainingPythonExe()
script := trainingScriptPath("predict_scene_model.py") script := trainingScriptPath("predict_scene_model.py")
@ -1987,9 +2183,9 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo
errText := strings.TrimSpace(stderr.String()) errText := strings.TrimSpace(stderr.String())
if err != nil { if err != nil {
fmt.Println("⚠️ scene position predict failed:", err) appLogln("⚠️ scene position predict failed:", err)
fmt.Println(" stdout:", outText) appLogln(" stdout:", outText)
fmt.Println(" stderr:", errText) appLogln(" stderr:", errText)
return TrainingScenePositionPrediction{ return TrainingScenePositionPrediction{
Available: false, Available: false,
@ -2000,7 +2196,7 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo
} }
if outText == "" { if outText == "" {
fmt.Println("⚠️ scene position predict empty stdout") appLogln("⚠️ scene position predict empty stdout")
return TrainingScenePositionPrediction{ return TrainingScenePositionPrediction{
Available: false, Available: false,
@ -2012,8 +2208,8 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo
var scenePred TrainingPrediction var scenePred TrainingPrediction
if err := json.Unmarshal([]byte(outText), &scenePred); err != nil { if err := json.Unmarshal([]byte(outText), &scenePred); err != nil {
fmt.Println("⚠️ scene position json failed:", err) appLogln("⚠️ scene position json failed:", err)
fmt.Println(" stdout:", outText) appLogln(" stdout:", outText)
return TrainingScenePositionPrediction{ return TrainingScenePositionPrediction{
Available: false, Available: false,
@ -2099,7 +2295,7 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
grouped, err := trainingGroupedLabels() grouped, err := trainingGroupedLabels()
if err != nil { if err != nil {
fmt.Println("⚠️ detector label grouping failed:", err) appLogln("⚠️ detector label grouping failed:", err)
return pred return pred
} }
@ -2197,7 +2393,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred
} }
} }
confValues := []string{"0.40", "0.30", "0.20"} confValues := []string{"0.30"}
best := TrainingDetectorPrediction{ best := TrainingDetectorPrediction{
Available: true, Available: true,
@ -2232,31 +2428,31 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred
errText := strings.TrimSpace(stderr.String()) errText := strings.TrimSpace(stderr.String())
if errText != "" { if errText != "" {
fmt.Println("🔎 detector stderr:", errText) appLogln("🔎 detector stderr:", errText)
} }
if err != nil { if err != nil {
fmt.Println("⚠️ detector predict failed") appLogln("⚠️ detector predict failed")
fmt.Println(" conf:", conf) appLogln(" conf:", conf)
fmt.Println(" error:", err) appLogln(" error:", err)
fmt.Println(" stdout:", outText) appLogln(" stdout:", outText)
fmt.Println(" stderr:", errText) appLogln(" stderr:", errText)
continue continue
} }
if outText == "" { if outText == "" {
fmt.Println("⚠️ detector predict empty stdout") appLogln("⚠️ detector predict empty stdout")
fmt.Println(" conf:", conf) appLogln(" conf:", conf)
fmt.Println(" stderr:", errText) appLogln(" stderr:", errText)
continue continue
} }
var det TrainingDetectorPrediction var det TrainingDetectorPrediction
if err := json.Unmarshal([]byte(outText), &det); err != nil { if err := json.Unmarshal([]byte(outText), &det); err != nil {
fmt.Println("⚠️ detector predict json failed:", err) appLogln("⚠️ detector predict json failed:", err)
fmt.Println(" conf:", conf) appLogln(" conf:", conf)
fmt.Println(" stdout:", outText) appLogln(" stdout:", outText)
fmt.Println(" stderr:", errText) appLogln(" stderr:", errText)
continue continue
} }
@ -2341,7 +2537,7 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete
grouped, err := trainingGroupedLabels() grouped, err := trainingGroupedLabels()
if err != nil { if err != nil {
fmt.Println("⚠️ detector label grouping failed:", err) appLogln("⚠️ detector label grouping failed:", err)
pred.Boxes = boxes pred.Boxes = boxes
pred.BodyPartsPresent = []TrainingScoredLabel{} pred.BodyPartsPresent = []TrainingScoredLabel{}
@ -2382,7 +2578,7 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
if _, err := os.Stat(srcFrame); err != nil { if _, err := os.Stat(srcFrame); err != nil {
return fmt.Errorf("frame missing: %w", err) return appErrorf("frame missing: %w", err)
} }
// Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split. // Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split.

View File

@ -50,12 +50,12 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json konnte nicht gelesen werden: %w", err) return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden: %w", err)
} }
var grouped TrainingGroupedLabels var grouped TrainingGroupedLabels
if err := json.Unmarshal(b, &grouped); err != nil { if err := json.Unmarshal(b, &grouped); err != nil {
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json ist ungültig: %w", err) return TrainingGroupedLabels{}, appErrorf("detection_labels.json ist ungültig: %w", err)
} }
grouped.People = uniqueNonEmptyLabels(grouped.People) grouped.People = uniqueNonEmptyLabels(grouped.People)
@ -69,11 +69,11 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
} }
if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels")
} }
if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels")
} }
return grouped, nil return grouped, nil

Binary file not shown.

BIN
backend/yolo26n.pt Normal file

Binary file not shown.

View File

@ -127,6 +127,22 @@ type TaskStateEvent = {
ts?: number ts?: number
} }
type AnalysisProgressEvent = {
type?: 'analysis_progress'
running?: boolean
phase?: 'starting' | 'running' | 'done' | 'error' | string
progress?: number
current?: number
total?: number
file?: string
message?: string
error?: string
startedAtMs?: number
finishedAtMs?: number
durationMs?: number
ts?: number
}
type BackgroundSplitProgress = { type BackgroundSplitProgress = {
phase: 'preparing' | 'splitting' | 'done' | 'error' phase: 'preparing' | 'splitting' | 'done' | 'error'
total: number total: number
@ -2822,6 +2838,75 @@ export default function App() {
} catch {} } catch {}
} }
const onAnalysisProgress = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
if (msg?.type !== 'analysis_progress') return
const file = baseName(String(msg?.file ?? '').trim())
if (!file) return
const phase = String(msg?.phase ?? '').trim().toLowerCase()
const progressRaw = Number(msg?.progress ?? 0)
const progress = Number.isFinite(progressRaw)
? Math.max(0, Math.min(1, progressRaw))
: 0
const totalRaw = Number(msg?.total ?? 0)
const total =
Number.isFinite(totalRaw) && totalRaw > 0
? Math.floor(totalRaw)
: 0
const percent = Math.round(progress * 100)
const state =
phase === 'error'
? 'error'
: phase === 'done'
? 'done'
: 'running'
const label =
phase === 'error'
? 'Analyse Fehler'
: phase === 'done'
? 'Analyse fertig'
: total > 0
? `Analyse ${percent}%`
: String(msg?.message ?? '').trim() || 'Analyse läuft'
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: file.replace(/\.[^.]+$/, ''),
queue: 'enrich',
phase: 'analyze',
state,
label,
error: String(msg?.error ?? '').trim() || undefined,
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
if (state === 'done' || state === 'error') {
bumpAssets()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}
} catch {
// ignore
}
}
const onTaskState = (ev: MessageEvent) => { const onTaskState = (ev: MessageEvent) => {
try { try {
const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
@ -2887,6 +2972,7 @@ export default function App() {
es.addEventListener('finishedPostwork', onFinishedPostwork as any) es.addEventListener('finishedPostwork', onFinishedPostwork as any)
es.addEventListener('taskState', onTaskState as any) es.addEventListener('taskState', onTaskState as any)
es.addEventListener('training', onTraining as any) es.addEventListener('training', onTraining as any)
es.addEventListener('analysisProgress', onAnalysisProgress as any)
const onVis = () => { const onVis = () => {
if (document.hidden) return if (document.hidden) return
@ -2912,6 +2998,7 @@ export default function App() {
es.removeEventListener('finishedPostwork', onFinishedPostwork as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any) es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any) es.removeEventListener('training', onTraining as any)
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
const handler = onModelJobEventRef.current const handler = onModelJobEventRef.current
if (handler) { if (handler) {
@ -3785,15 +3872,29 @@ export default function App() {
const mobileHeaderStatBadgeClass = 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' '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 isTrainingTab = selectedTab === 'training'
return ( return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> <div
className={[
isTrainingTab
? 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden'
: 'min-h-[100dvh]',
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
].join(' ')}
>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden"> <div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" /> <div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" /> <div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
</div> </div>
<div className="relative"> <div
<header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10"> className={[
'relative',
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
].join(' ')}
>
<header className="z-[70] shrink-0 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-2.5 sm:py-4 space-y-2 sm:space-y-3"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-2.5 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center justify-between gap-3 sm:gap-4"> <div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -3993,13 +4094,20 @@ export default function App() {
</div> </div>
</header> </header>
<div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60"> <div className="sm:hidden sticky top-0 z-[65] shrink-0 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2"> <div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" /> <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div> </div>
</div> </div>
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 space-y-2"> <main
className={[
'mx-auto w-full max-w-7xl px-4 py-2 sm:px-6 lg:px-8',
isTrainingTab
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
: 'space-y-2',
].join(' ')}
>
{selectedTab === 'running' ? ( {selectedTab === 'running' ? (
<Downloads <Downloads
jobs={jobs} jobs={jobs}

View File

@ -26,7 +26,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
} }
const base = const base =
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 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'
const roundedMap = { const roundedMap = {
sm: 'rounded-sm', sm: 'rounded-sm',
@ -47,8 +47,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' + '!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', 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary: secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', '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',
soft: 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 ' + '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', 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30',
@ -59,8 +61,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' + '!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', 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary: secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', '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',
soft: 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 ' + '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', 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30',
@ -71,8 +75,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' + '!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', 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary: secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', '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',
soft: 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 ' + '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', 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30',
@ -83,8 +89,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' + '!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', 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary: secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', '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',
soft: 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 ' + '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', 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30',
@ -95,8 +103,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' + '!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', 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary: secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20', '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',
soft: 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 ' + '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', 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30',

View File

@ -1118,15 +1118,15 @@ function summarizePostworkSteps(
const rawPhase = String(badge.phase ?? '').trim().toLowerCase() const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
const phase = normalizeFinishedPhase(queue, rawPhase) const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}` const key = `${queue}:${phase}`
const badgeLabel = String(badge.label ?? '').trim()
const prev = byKey.get(key) const normalizedBadgeLabel = normalizeAnalysisProgressLabel(badgeLabel) ?? badgeLabel
if (prev && prev.ts > badge.ts) continue
byKey.set(key, { const candidate: FinishedPostworkStepSummary = {
key, key,
queue, queue,
phase, phase,
label: labelForStep(queue, phase), label: normalizedBadgeLabel || labelForStep(queue, phase),
state: badge.state, state: badge.state,
error: String(badge.error ?? '').trim() || undefined, error: String(badge.error ?? '').trim() || undefined,
ts: badge.ts, ts: badge.ts,
@ -1134,7 +1134,20 @@ function summarizePostworkSteps(
waiting: badge.waiting, waiting: badge.waiting,
running: badge.running, running: badge.running,
maxParallel: badge.maxParallel, maxParallel: badge.maxParallel,
}) }
const prev = byKey.get(key)
if (prev) {
if ((prev.ts || 0) > (candidate.ts || 0)) {
byKey.set(key, preferProgressStepLabel(prev, candidate))
} else {
byKey.set(key, preferProgressStepLabel(candidate, prev))
}
continue
}
byKey.set(key, candidate)
} }
// ✅ Erst alle erwarteten Schritte anlegen // ✅ Erst alle erwarteten Schritte anlegen
@ -1238,9 +1251,19 @@ function mergePostworkSummaries(
step.state === 'queued' || step.state === 'queued' ||
step.state === 'error' step.state === 'error'
) { ) {
if (!prev || (step.ts || 0) >= (prev.ts || 0)) { if (!prev) {
stepMap.set(step.key, step) stepMap.set(step.key, step)
continue
} }
if ((step.ts || 0) >= (prev.ts || 0)) {
stepMap.set(step.key, preferProgressStepLabel(step, prev))
continue
}
// Auch wenn der ältere Step vom Polling kommt:
// Ein vorhandenes Analyse-Prozentlabel darf nicht durch "Analyse" verloren gehen.
stepMap.set(step.key, preferProgressStepLabel(prev, step))
continue continue
} }
@ -1341,6 +1364,150 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge):
) )
} }
function analysisProgressPercent(label?: string): number | null {
const s = String(label ?? '').trim()
const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i)
if (percentMatch) {
const n = Number(percentMatch[1])
if (!Number.isFinite(n)) return null
return Math.max(0, Math.min(100, Math.round(n)))
}
const framesMatch = s.match(/^analyse\s+(\d+)\s*\/\s*(\d+)\s*frames?$/i)
if (framesMatch) {
const current = Number(framesMatch[1])
const total = Number(framesMatch[2])
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
return null
}
function postworkStepDisplayLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
return 'Analyse'
}
return step.label
}
function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
const percent = analysisProgressPercent(step.label)
if (percent != null) return `${percent}%`
}
return 'Läuft'
}
function normalizeAnalysisProgressLabel(label?: string): string | undefined {
const percent = analysisProgressPercent(label)
if (percent == null) return label
return `Analyse ${percent}%`
}
function isAnalyzeBadge(badge?: Pick<FinishedPostworkBadge, 'queue' | 'phase'> | null): boolean {
return (
badge?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', badge.phase) === 'analyze'
)
}
function isAnalyzeStep(step?: Pick<FinishedPostworkStepSummary, 'queue' | 'phase'> | null): boolean {
return (
step?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', step.phase) === 'analyze'
)
}
function preferProgressLabel(
incoming: FinishedPostworkBadge,
previous?: FinishedPostworkBadge
): FinishedPostworkBadge {
if (!isAnalyzeBadge(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkBadge = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label),
}
if (!previous || previous.state !== 'running' || !isAnalyzeBadge(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function preferProgressStepLabel(
incoming: FinishedPostworkStepSummary,
previous?: FinishedPostworkStepSummary
): FinishedPostworkStepSummary {
if (!isAnalyzeStep(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkStepSummary = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label) ?? incoming.label,
}
if (!previous || previous.state !== 'running' || !isAnalyzeStep(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function samePostworkBadgeList( function samePostworkBadgeList(
a?: FinishedPostworkBadge[], a?: FinishedPostworkBadge[],
b?: FinishedPostworkBadge[] b?: FinishedPostworkBadge[]
@ -1377,6 +1544,214 @@ function samePostworkBadgeMap(
return true return true
} }
function postworkSummaryTitle(summary: FinishedPostworkSummary): string {
switch (summary.state) {
case 'running':
return 'Postwork läuft'
case 'queued':
return 'Postwork wartet'
case 'done':
return 'Postwork fertig'
case 'error':
return 'Postwork Fehler'
case 'missing':
return 'Postwork fehlt'
default:
return 'Postwork-Status'
}
}
function postworkSummaryIcon(summary: FinishedPostworkSummary): ReactNode {
const iconClass = 'size-5 shrink-0'
switch (summary.state) {
case 'running':
return <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
case 'queued':
return <ClockIcon className={`${iconClass} text-amber-600 dark:text-amber-300`} />
case 'done':
return <CheckCircleIcon className={`${iconClass} text-emerald-600 dark:text-emerald-300`} />
case 'error':
return <ExclamationTriangleIcon className={`${iconClass} text-red-600 dark:text-red-300`} />
case 'missing':
return <span className="size-5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
default:
return <span className="size-5 shrink-0" />
}
}
function postworkDoneCount(summary: FinishedPostworkSummary): number {
return summary.steps.filter((step) => step.state === 'done').length
}
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
const total = summary.steps.length
if (total <= 0) return 0
const done = postworkDoneCount(summary)
const running = summary.steps.some((step) => step.state === 'running') ? 0.5 : 0
return Math.max(0, Math.min(100, Math.round(((done + running) / total) * 100)))
}
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
if (step.state === 'running') return runningStepStateLabel(step)
if (step.state === 'queued') {
return typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'
}
if (step.state === 'done') return 'Fertig'
if (step.state === 'error') return 'Fehler'
if (step.state === 'missing') return 'Offen'
return '—'
}
function postworkStepTone(step: FinishedPostworkStepSummary): string {
switch (step.state) {
case 'running':
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
case 'queued':
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
case 'done':
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
case 'error':
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
default:
return 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10'
}
}
function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode {
if (step.state === 'running') {
return <LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
}
if (step.state === 'queued') {
return <ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
}
if (step.state === 'done') {
return <CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
}
if (step.state === 'error') {
return <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
}
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
}
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
const done = postworkDoneCount(summary)
const total = summary.steps.length
const progress = postworkProgressPercent(summary)
return (
<div className="w-[340px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
<div className="border-b border-gray-200/60 bg-gradient-to-br from-gray-50 to-white px-3 py-2.5 dark:border-white/10 dark:from-white/10 dark:to-white/5">
<div className="flex items-center gap-2.5">
<div className="grid size-7 shrink-0 place-items-center rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
{postworkSummaryIcon(summary)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[13px] font-semibold text-gray-900 dark:text-white">
{postworkSummaryTitle(summary)}
</div>
<span
className={[
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
postworkBadgeTone(summary),
].join(' ')}
>
{summary.label}
</span>
</div>
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
{summary.file}
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className={[
'h-full rounded-full transition-all duration-300',
summary.state === 'error'
? 'bg-red-500'
: summary.state === 'queued'
? 'bg-amber-500'
: summary.state === 'running'
? 'bg-indigo-500'
: 'bg-emerald-500',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{done}/{total}
</span>
</div>
</div>
<div className="px-3 py-3">
<div className="space-y-1.5">
{summary.steps.map((step) => (
<div
key={step.key}
className={[
'grid grid-cols-[minmax(0,1fr)_24px_auto] items-center gap-2 rounded-xl px-2.5 py-2',
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
].join(' ')}
>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-gray-900 dark:text-gray-100">
{postworkStepDisplayLabel(step)}
</div>
{step.state === 'error' && step.error ? (
<div className="mt-0.5 truncate text-[11px] text-red-600 dark:text-red-300" title={step.error}>
{step.error}
</div>
) : null}
</div>
<div className="flex size-6 items-center justify-center">
{postworkStepIcon(step)}
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'inline-flex min-w-[64px] justify-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkStepTone(step),
].join(' ')}
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
>
{postworkStepStateLabel(step)}
</span>
{step.state === 'error' && step.error ? (
<CopyErrorButton text={step.error} />
) : null}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export function renderFinishedPostworkBadge( export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary, summary?: FinishedPostworkSummary,
opts?: { opts?: {
@ -1444,73 +1819,7 @@ export function renderFinishedPostworkBadge(
return ( return (
<HoverPopover <HoverPopover
content={(open) => content={(open) => open && renderPostworkPopover(summary)}
open && (
<div className="w-[280px] rounded-lg">
<div className="px-3 py-2 border-b border-gray-200 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate">
{summary.file}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{summary.steps.map((step) => (
<div key={step.key} className="flex items-center justify-between gap-3">
<div className="min-w-0 text-[12px] text-gray-800 dark:text-gray-200">
{step.label}
</div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px]">
{step.state === 'running' ? (
<>
<LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
<span className="text-indigo-700 dark:text-indigo-200">Läuft</span>
</>
) : step.state === 'queued' ? (
<>
<ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="text-amber-700 dark:text-amber-200">
{typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'}
</span>
</>
) : step.state === 'done' ? (
<>
<CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
</>
) : step.state === 'error' ? (
<div className="flex items-center gap-1.5">
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
<span
className="text-red-700 dark:text-red-200"
title={step.error || 'Unbekannter Fehler'}
>
Fehler
</span>
{step.error ? <CopyErrorButton text={step.error} /> : null}
</div>
) : step.state === 'missing' ? (
<>
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
<span className="text-gray-500 dark:text-gray-400">Kein Status</span>
</>
) : (
<span className="text-gray-500 dark:text-gray-400"></span>
)}
</div>
</div>
))}
</div>
</div>
)
}
> >
{badgeNode} {badgeNode}
</HoverPopover> </HoverPopover>
@ -3636,9 +3945,31 @@ export default function FinishedDownloads({
: {} : {}
setGenerateAssetsLiveByFile((prev) => { setGenerateAssetsLiveByFile((prev) => {
return samePostworkBadgeMap(prev, nextGenerateAssetsLiveByFile) const patched: Record<string, FinishedPostworkBadge[]> = {}
? prev
: nextGenerateAssetsLiveByFile for (const [file, badges] of Object.entries(nextGenerateAssetsLiveByFile)) {
const prevBadges = prev[file] ?? postworkByFile[file] ?? []
patched[file] = badges.map((badge) => {
const key = `${badge.queue}:${normalizeFinishedPhase(
badge.queue,
String(badge.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadges.find((p) => {
const prevKey = `${p.queue}:${normalizeFinishedPhase(
p.queue,
String(p.phase ?? '').trim().toLowerCase()
)}`
return prevKey === key
})
return preferProgressLabel(badge, previous)
})
}
return samePostworkBadgeMap(prev, patched) ? prev : patched
}) })
const jobs = Array.isArray(data?.regenerateAssetsJobs) const jobs = Array.isArray(data?.regenerateAssetsJobs)
@ -3665,6 +3996,8 @@ export default function FinishedDownloads({
const phases = Array.isArray(item?.phases) ? item.phases : [] const phases = Array.isArray(item?.phases) ? item.phases : []
if (phases.length > 0) { if (phases.length > 0) {
const prevBadgesForFile = prev[file] ?? []
nextBadges = phases nextBadges = phases
.map((p: any): FinishedPostworkBadge | null => { .map((p: any): FinishedPostworkBadge | null => {
const queueRaw = String(p?.queue ?? '').trim().toLowerCase() const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
@ -3689,21 +4022,40 @@ export default function FinishedDownloads({
return null return null
} }
return { const queue = queueRaw as 'postwork' | 'enrich'
const phase = normalizeFinishedPhase(queue, phaseRaw)
const incoming: FinishedPostworkBadge = {
file, file,
assetId, assetId,
queue: queueRaw as 'postwork' | 'enrich', queue,
phase: phaseRaw, phase,
state, state,
label: label:
String(p?.label ?? '').trim() || normalizeAnalysisProgressLabel(String(p?.label ?? '').trim()) ||
labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), labelForStep(queue, phase),
position: typeof p?.position === 'number' ? p.position : undefined, position: typeof p?.position === 'number' ? p.position : undefined,
waiting: typeof p?.waiting === 'number' ? p.waiting : undefined, waiting: typeof p?.waiting === 'number' ? p.waiting : undefined,
running: typeof p?.running === 'number' ? p.running : undefined, running: typeof p?.running === 'number' ? p.running : undefined,
maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined, maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined,
ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0, ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0,
} }
const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadgesForFile.find((old) => {
const oldKey = `${old.queue}:${normalizeFinishedPhase(
old.queue,
String(old.phase ?? '').trim().toLowerCase()
)}`
return oldKey === key
})
return preferProgressLabel(incoming, previous)
}) })
.filter(Boolean) as FinishedPostworkBadge[] .filter(Boolean) as FinishedPostworkBadge[]
} else { } else {
@ -3763,7 +4115,7 @@ export default function FinishedDownloads({
} catch { } catch {
// ignore // ignore
} }
}, []) }, [postworkByFile])
useEffect(() => { useEffect(() => {
void refreshPostworkState() void refreshPostworkState()
@ -3943,9 +4295,20 @@ export default function FinishedDownloads({
const cur = Array.isArray(prev[file]) ? prev[file] : [] const cur = Array.isArray(prev[file]) ? prev[file] : []
if (detail.state === 'missing') { if (detail.state === 'missing') {
const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}` const detailQueue = detail.queue === 'enrich' ? 'enrich' : 'postwork'
const detailPhase = normalizeFinishedPhase(
detailQueue,
String(detail.phase ?? '').trim().toLowerCase()
)
const missingKey = `${detailQueue}:${detailPhase}`
const filtered = cur.filter((item) => { const filtered = cur.filter((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
return itemKey !== missingKey return itemKey !== missingKey
}) })
@ -3967,14 +4330,27 @@ export default function FinishedDownloads({
error: String(detail?.error ?? '').trim() || undefined, error: String(detail?.error ?? '').trim() || undefined,
} }
const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}` const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
let replaced = false let replaced = false
const merged = cur.map((item) => { const merged = cur.map((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
if (itemKey !== key) return item if (itemKey !== key) return item
replaced = true replaced = true
return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming
if ((item.ts ?? 0) > (incoming.ts ?? 0)) {
return item
}
return preferProgressLabel(incoming, item)
}) })
const nextBadges = replaced ? merged : [...merged, incoming] const nextBadges = replaced ? merged : [...merged, incoming]

File diff suppressed because it is too large Load Diff

View File

@ -193,7 +193,7 @@ export default function Modal({
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title ? ( {title ? (
<Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate"> <Dialog.Title className="block text-base font-semibold text-gray-900 dark:text-white truncate">
{title} {title}
</Dialog.Title> </Dialog.Title>
) : null} ) : null}

View File

@ -586,6 +586,9 @@ export default function Player({
const playerRef = useRef<VideoJsPlayer | null>(null) const playerRef = useRef<VideoJsPlayer | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null) const videoNodeRef = useRef<HTMLVideoElement | null>(null)
const mobileSegmentsScrollRef = useRef<HTMLDivElement | null>(null)
const mobileHeaderTouchYRef = useRef<number | null>(null)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const updateIntrinsicDims = useCallback(() => { const updateIntrinsicDims = useCallback(() => {
@ -1960,7 +1963,7 @@ export default function Player({
<div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div> <div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>
<div className="shrink-0 flex items-center gap-1 pointer-events-auto"> <div className="shrink-0 flex items-center gap-1 pointer-events-auto">
{miniDesktop && !isLive && hasRatingSegments ? ( {!isLive && hasRatingSegments ? (
<button <button
type="button" type="button"
className={cn( className={cn(
@ -2359,6 +2362,115 @@ export default function Player({
</div> </div>
) : null ) : null
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
const MOBILE_PLAYER_GAP = 10
const mobilePlayerBottom = `calc(${bottomInset}px + env(safe-area-inset-bottom))`
const mobileSegmentsBottom = `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)`
const mobileSegmentsMaxHeight = `calc(100dvh - ${mobileSegmentsBottom} - 12px)`
const mobileSegmentsSheetEl =
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
<div
className="
fixed inset-x-2 z-[2147483647]
flex min-h-0 flex-col overflow-hidden rounded-2xl
border border-white/10 bg-white shadow-2xl ring-1 ring-black/10
dark:bg-gray-950 dark:ring-white/10
sm:hidden
"
style={{
bottom: mobileSegmentsBottom,
maxHeight: mobileSegmentsMaxHeight,
height: mobileSegmentsMaxHeight,
touchAction: 'pan-y',
}}
onClick={(e) => e.stopPropagation()}
onTouchMoveCapture={(e) => {
e.stopPropagation()
}}
onWheelCapture={(e) => {
e.stopPropagation()
}}
>
<div
className="shrink-0 flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10"
style={{
touchAction: 'none',
}}
onTouchStart={(e) => {
mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null
e.stopPropagation()
}}
onTouchMove={(e) => {
const y = e.touches[0]?.clientY ?? null
const lastY = mobileHeaderTouchYRef.current
if (y != null && lastY != null) {
const dy = lastY - y
mobileSegmentsScrollRef.current?.scrollBy({ top: dy })
mobileHeaderTouchYRef.current = y
}
e.preventDefault()
e.stopPropagation()
}}
onTouchEnd={(e) => {
mobileHeaderTouchYRef.current = null
e.stopPropagation()
}}
onWheel={(e) => {
mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY })
e.preventDefault()
e.stopPropagation()
}}
>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Interessante Stellen
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{ratingSegments.length} Segmente
</div>
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSegmentsPanelOpen(false)
}}
aria-label="Segmente schließen"
title="Schließen"
>
×
</button>
</div>
<div
ref={mobileSegmentsScrollRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 [-webkit-overflow-scrolling:touch]"
style={{
touchAction: 'pan-y',
}}
onClick={(e) => {
e.stopPropagation()
}}
>
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={(seconds) => {
seekPlayerToAbsolute(seconds)
}}
maxHeight="100%"
className="h-full max-h-full shadow-none"
/>
</div>
</div>
) : null
const expandedRect = { const expandedRect = {
left: ox + 16, left: ox + 16,
top: oy + 16, top: oy + 16,
@ -2532,18 +2644,22 @@ export default function Player({
) : null} ) : null}
</div> </div>
) : ( ) : (
<div <>
className=" {mobileSegmentsSheetEl}
fixed z-[2147483647] inset-x-0 w-full
shadow-2xl <div
md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2 className="
" fixed z-[2147483647] inset-x-0 w-full
style={{ shadow-2xl
bottom: `calc(${bottomInset}px + env(safe-area-inset-bottom))`, md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
}} "
> style={{
{cardEl} bottom: mobilePlayerBottom,
</div> }}
>
{cardEl}
</div>
</>
)} )}
</> </>
) )

View File

@ -2,10 +2,16 @@
'use client' 'use client'
import { type CSSProperties } from 'react' import {
useEffect,
useState,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { createPortal } from 'react-dom'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import type { RecordJobMeta } from '../../types' import type { RecordJobMeta } from '../../types'
import { getSegmentLabelText } from './Icons'
type RatingOverlayProps = { type RatingOverlayProps = {
metaRaw: unknown metaRaw: unknown
@ -61,68 +67,166 @@ const VALUE_LABELS: Record<string, string> = {
} }
const AI_LABEL_FALLBACKS: Record<string, string> = { const AI_LABEL_FALLBACKS: Record<string, string> = {
// entspricht SEGMENT_LABEL_META in Icons.tsx vulva: 'Vagina',
anus_exposed: 'Anus', pussy: 'Vagina',
penis: 'Penis',
anus: 'Anus', anus: 'Anus',
ass: 'Hintern',
female_genitalia_exposed: 'Vagina', breasts: 'Brüste',
vulva_exposed: 'Vagina',
labia_exposed: 'Vagina',
male_genitalia_exposed: 'Penis',
penis_exposed: 'Penis',
female_breast_exposed: 'Brüste',
breast_exposed: 'Brüste',
male_breast_exposed: 'Männliche Brust',
buttocks_exposed: 'Hintern',
buttocks: 'Hintern', buttocks: 'Hintern',
tongue: 'Zunge',
belly_exposed: 'Bauch', blindfold: 'Augenbinde',
stomach_exposed: 'Bauch', buttplug: 'Buttplug',
abdomen_exposed: 'Bauch', collar: 'Halsband',
navel_exposed: 'Bauch', dildo: 'Dildo',
handcuffs: 'Handschellen',
shower: 'Dusche',
strapon: 'Strapon',
towel: 'Handtuch',
vibrator: 'Vibrator',
feet_exposed: 'Füße', bikini: 'Bikini',
foot_exposed: 'Füße', bra: 'BH',
dress: 'Kleid',
face_female: 'Gesicht', heels: 'High Heels',
face_male: 'Gesicht', hotpants: 'Hotpants',
face: 'Gesicht',
female_face: 'Gesicht',
male_face: 'Gesicht',
// covered-Varianten passend zur Logik in Icons.tsx
anus_covered: 'Anus bedeckt',
buttocks_covered: 'Hintern bedeckt',
female_breast_covered: 'Brüste bedeckt',
breast_covered: 'Brüste bedeckt',
male_breast_covered: 'Männliche Brust bedeckt',
female_genitalia_covered: 'Vagina bedeckt',
vulva_covered: 'Vagina bedeckt',
labia_covered: 'Vagina bedeckt',
male_genitalia_covered: 'Penis bedeckt',
penis_covered: 'Penis bedeckt',
belly_covered: 'Bauch bedeckt',
stomach_covered: 'Bauch bedeckt',
abdomen_covered: 'Bauch bedeckt',
navel_covered: 'Bauch bedeckt',
feet_covered: 'Füße bedeckt',
foot_covered: 'Füße bedeckt',
face_covered: 'Gesicht bedeckt',
// allgemeine AI-/Safety-Labels
explicit_nudity: 'Explizite Nacktheit',
non_explicit_nudity: 'Nicht-explizite Nacktheit',
suggestive: 'Suggestiv',
sexual_activity: 'Sexuelle Aktivität',
sexual_display: 'Sexuelle Darstellung',
sex_toy: 'Sexspielzeug',
underwear: 'Unterwäsche',
lingerie: 'Lingerie', lingerie: 'Lingerie',
swimwear: 'Bademode', panties: 'Slip',
skirt: 'Rock',
stockings: 'Strümpfe',
croptop: 'Crop Top',
unknown: 'Unbekannt',
missionary: 'Missionary',
doggy: 'Doggy',
cowgirl: 'Cowgirl',
reverse_cowgirl: 'Reverse Cowgirl',
cunnilingus: 'Cunnilingus',
prone_bone: 'Prone Bone',
standing: 'Stehend',
standing_doggy: 'Standing Doggy',
spooning: 'Spooning',
sitting: 'Sitzend',
facesitting: 'Facesitting',
handjob: 'Handjob',
blowjob: 'Blowjob',
toy_play: 'Toy Play',
fingering: 'Fingering',
'69': '69',
other: 'Andere',
person: 'Person',
person_unknown: 'Person',
person_male: 'Mann',
person_female: 'Frau',
}
const PERSON_SEGMENT_LABELS = new Set([
'person',
'person_unknown',
'person_male',
'person_female',
'male_person',
'female_person',
'people_male',
'people_female',
'frau',
'mann',
'person',
])
function normalizeSegmentLabel(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/^detector:/, '')
.replace(/^body:/, '')
.replace(/^object:/, '')
.replace(/^clothing:/, '')
.replace(/^position:/, '')
}
function isPersonSegmentLabel(value: unknown): boolean {
return PERSON_SEGMENT_LABELS.has(normalizeSegmentLabel(value))
}
function splitMaybeComboOrList(value: unknown): string[] {
return String(value ?? '')
.split(/[+,;|]+/g)
.map((part) => part.trim())
.filter(Boolean)
}
function isNonPersonHighlightLabel(value: unknown): boolean {
const parts = splitMaybeComboOrList(value)
for (const part of parts) {
const normalized = normalizeSegmentLabel(part)
if (!normalized) continue
if (!isPersonSegmentLabel(normalized)) {
return true
}
}
return false
}
function segmentObjectHasNonPersonContext(obj: Record<string, unknown>): boolean {
const values: unknown[] = [
obj.tags,
obj.labels,
obj.classes,
obj.flags,
obj.summary,
obj.reason,
obj.description,
obj.text,
obj.caption,
obj.note,
]
for (const value of values) {
if (Array.isArray(value)) {
if (value.some((item) => isNonPersonHighlightLabel(item))) {
return true
}
continue
}
if (typeof value === 'string' && isNonPersonHighlightLabel(value)) {
return true
}
}
return false
}
function isPersonSegmentObject(obj: Record<string, unknown>): boolean {
const rawLabel =
obj.label ??
obj.title ??
obj.category ??
obj.type ??
obj.kind ??
obj.name ??
''
// Wenn der Haupttitel keine reine Person ist, behalten.
// Beispiel: combo:person_female+object:dildo
if (!isPersonSegmentLabel(rawLabel)) {
return false
}
// Wenn es Zusatzkontext gibt, behalten.
// Beispiel: label=person_female, tags=[dildo, vibrator]
if (segmentObjectHasNonPersonContext(obj)) {
return false
}
// Wirklich nur "Frau", "Mann", "Person" alleine ausblenden.
return true
} }
function isAiLikeLabel(value: string): boolean { function isAiLikeLabel(value: string): boolean {
@ -150,18 +254,33 @@ function prettyAiLabel(value: unknown): string {
const key = raw.toLowerCase() const key = raw.toLowerCase()
try { if (key.startsWith('position:')) {
const fromIcons = getSegmentLabelText(raw).trim() return prettyAiLabel(key.slice('position:'.length))
}
if ( if (key.startsWith('object:')) {
fromIcons && return prettyAiLabel(key.slice('object:'.length))
fromIcons !== raw && }
fromIcons.toLowerCase() !== key
) { if (key.startsWith('clothing:')) {
return fromIcons return prettyAiLabel(key.slice('clothing:'.length))
} }
} catch {
// fallback below if (key.startsWith('body:')) {
return prettyAiLabel(key.slice('body:'.length))
}
if (key.startsWith('detector:')) {
return prettyAiLabel(key.slice('detector:'.length))
}
if (key.startsWith('combo:')) {
const rest = key.slice('combo:'.length)
return rest
.split('+')
.map((part) => prettyAiLabel(part))
.filter(Boolean)
.join(' + ')
} }
const direct = AI_LABEL_FALLBACKS[key] const direct = AI_LABEL_FALLBACKS[key]
@ -170,6 +289,23 @@ function prettyAiLabel(value: unknown): string {
return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw
} }
function segmentTitleFromRawLabel(raw: string): string {
return prettyAiLabel(raw)
}
function readSegmentTags(obj: Record<string, unknown>): string[] {
const tags = readTags(obj)
const out: string[] = []
for (const tag of tags) {
if (!tag) continue
if (out.some((x) => x.toLowerCase() === tag.toLowerCase())) continue
out.push(tag)
}
return out.slice(0, 6)
}
function prettyMaybeAiValue(key: string, value: string): string { function prettyMaybeAiValue(key: string, value: string): string {
const k = key.toLowerCase() const k = key.toLowerCase()
@ -803,26 +939,111 @@ function segmentTimeLabel(obj: Record<string, unknown>): string | undefined {
function readSegmentArray(metaRaw: unknown): unknown[] { function readSegmentArray(metaRaw: unknown): unknown[] {
const meta = parseMetaObject(metaRaw) as any const meta = parseMetaObject(metaRaw) as any
const ai = readAiNode(metaRaw) as any const ai = readAiNode(metaRaw) as any
const rating = readRatingNode(metaRaw) as any
const candidates = [ const arrays: unknown[][] = []
ai?.segments,
ai?.ratingSegments,
ai?.hits,
ai?.matches,
rating?.segments,
rating?.hits,
meta?.analysis?.segments,
meta?.analysis?.hits,
]
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length > 0) { const pushSegmentSources = (node: unknown) => {
return candidate if (!isPlainObject(node)) return
const n = node as any
// Bevorzugt echte Segmente. Hits nur als Fallback,
// sonst werden dieselben Stellen doppelt angezeigt.
const preferred = [
n.segments,
n.ratingSegments,
n.matches,
n.hits,
]
for (const value of preferred) {
if (Array.isArray(value) && value.length > 0) {
arrays.push(value)
return
}
} }
} }
return [] // Wichtig: Highlights zuerst, damit Positionen/Combos oben erscheinen.
const goalNodes = [
meta?.analysis?.highlights,
meta?.analysis?.highlight,
ai?.highlights,
ai?.highlight,
ai?.goals?.highlights,
ai?.goals?.highlight,
meta?.analysis?.ai?.highlights,
meta?.analysis?.ai?.highlight,
]
for (const node of goalNodes) {
pushSegmentSources(node)
}
if (arrays.length === 0) return []
const out: unknown[] = []
const seen = new Set<string>()
for (const arr of arrays) {
for (const item of arr) {
if (!isPlainObject(item)) continue
const obj = item as Record<string, unknown>
if (isPersonSegmentObject(obj)) continue
const label = String(
obj.label ??
obj.title ??
obj.category ??
obj.type ??
obj.kind ??
obj.name ??
''
).trim()
const start = firstNumber(obj, [
'startSec',
'startSeconds',
'start_s',
'start',
'from',
'time',
't',
'timestamp',
'startMs',
])
const end = firstNumber(obj, [
'endSec',
'endSeconds',
'end_s',
'end',
'to',
'endMs',
])
const key = [
normalizeSegmentLabel(label),
typeof start === 'number' ? start.toFixed(1) : '',
typeof end === 'number' ? end.toFixed(1) : '',
].join('|')
if (seen.has(key)) continue
seen.add(key)
out.push(item)
}
}
return out
}
function isRatingSegment(value: RatingSegment | null): value is RatingSegment {
return value !== null
} }
export function readRatingSegments(metaRaw: unknown): RatingSegment[] { export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
@ -838,7 +1059,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ?? firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ??
`Segment ${index + 1}` `Segment ${index + 1}`
const title = prettyAiLabel(rawTitle) const title = segmentTitleFromRawLabel(rawTitle)
const rawDetail = const rawDetail =
firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ?? firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ??
@ -865,13 +1086,12 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
timeLabel: segmentTimeLabel(raw), timeLabel: segmentTimeLabel(raw),
valueLabel: segmentValueLabel(raw), valueLabel: segmentValueLabel(raw),
detail, detail,
tags: readTags(raw), tags: readSegmentTags(raw),
preview, preview,
openAtSec, openAtSec,
} }
}) })
.filter(Boolean) .filter(isRatingSegment)
.slice(0, 10) as RatingSegment[]
} }
export function RatingSegmentsPanel({ export function RatingSegmentsPanel({
@ -911,10 +1131,10 @@ export function RatingSegmentsPanel({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="text-xs font-semibold text-gray-900 dark:text-white"> <div className="text-xs font-semibold text-gray-900 dark:text-white">
Segmente Interessante Stellen
</div> </div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400"> <div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
AI Rating · {segments.length} Positionen · Highlights · AI · {segments.length}
</div> </div>
</div> </div>
</div> </div>
@ -1100,6 +1320,173 @@ function RatingStar({
) )
} }
function useTouchLikePointer(): boolean {
const [touchLike, setTouchLike] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const queries = [
window.matchMedia('(hover: none)'),
window.matchMedia('(pointer: coarse)'),
]
const update = () => {
setTouchLike(queries.some((query) => query.matches))
}
update()
for (const query of queries) {
if (query.addEventListener) query.addEventListener('change', update)
else query.addListener(update)
}
return () => {
for (const query of queries) {
if (query.removeEventListener) query.removeEventListener('change', update)
else query.removeListener(update)
}
}
}, [])
return touchLike
}
function RatingMobileSheet({
open,
stars,
toneLabel,
entries,
segments,
onClose,
onOpenAt,
}: {
open: boolean
stars: number
toneLabel: string
entries: RatingEntry[]
segments: RatingSegment[]
onClose: () => void
onOpenAt?: (seconds: number) => void
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open, onClose])
if (!open || typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-[2147483647] bg-black/45 backdrop-blur-sm sm:hidden"
role="dialog"
aria-modal="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
>
<div
className="absolute inset-x-0 bottom-0 max-h-[82dvh] overflow-hidden rounded-t-2xl border border-white/10 bg-white shadow-2xl dark:bg-gray-950"
style={{
paddingBottom: 'env(safe-area-inset-bottom)',
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{toneLabel} · {stars}/5 Sterne
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<RatingStar stars={stars} className="h-8 w-8" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
aria-label="Schließen"
title="Schließen"
>
×
</button>
</div>
</div>
<div className="max-h-[calc(82dvh-58px)] overflow-y-auto overscroll-contain px-3 py-3">
{entries.length > 0 ? (
<div className="mb-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="space-y-1.5">
{entries.map((entry) => (
<div
key={entry.key}
className="flex items-start justify-between gap-3 text-[12px]"
>
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
>
{entry.value}
</span>
</div>
))}
</div>
</div>
) : null}
{segments.length > 0 ? (
<RatingSegmentsPanel
segments={segments}
onOpenAt={
onOpenAt
? (seconds) => {
onClose()
onOpenAt(seconds)
}
: undefined
}
maxHeight="none"
className="max-h-none shadow-none"
/>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-4 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine interessanten Stellen vorhanden.
</div>
)}
</div>
</div>
</div>,
document.body
)
}
function RatingWithPopover({ function RatingWithPopover({
stars, stars,
metaRaw, metaRaw,
@ -1115,195 +1502,137 @@ function RatingWithPopover({
const segments = readRatingSegments(metaRaw) const segments = readRatingSegments(metaRaw)
const tone = ratingTone(stars) const tone = ratingTone(stars)
const touchLike = useTouchLikePointer()
const [tapOpen, setTapOpen] = useState(false)
useEffect(() => {
if (!touchLike) setTapOpen(false)
}, [touchLike])
const hasContent = entries.length > 0 || segments.length > 0
const starNode = ( const starNode = (
<span <span
className="inline-flex items-center justify-center" role={touchLike ? 'button' : undefined}
tabIndex={touchLike ? 0 : undefined}
className="inline-flex items-center justify-center touch-manipulation"
aria-label={`Rating ${stars} von 5`} aria-label={`Rating ${stars} von 5`}
title={`Rating ${stars}/5`} title={
hasContent
? `Rating ${stars}/5 · Details anzeigen`
: `Rating ${stars}/5`
}
onClick={(e: MouseEvent<HTMLSpanElement>) => {
if (!touchLike) return
e.preventDefault()
e.stopPropagation()
setTapOpen((v) => !v)
}}
onKeyDown={(e: KeyboardEvent<HTMLSpanElement>) => {
if (!touchLike) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
setTapOpen((v) => !v)
}}
> >
<RatingStar stars={stars} className={className} /> <RatingStar stars={stars} className={className} />
</span> </span>
) )
if (entries.length === 0 && segments.length === 0) { if (!hasContent) {
return starNode return starNode
} }
return ( const desktopPopoverContent = (
<HoverPopover <div className="w-[360px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl ring-1 ring-black/5 dark:border-white/10 dark:bg-gray-950 dark:ring-white/10">
content={(open) => <div className="border-b border-gray-200 px-3 py-2 dark:border-white/10">
open && ( <div className="flex items-center justify-between gap-3">
<div className="w-[340px] overflow-hidden rounded-lg"> <div>
<div className="border-b border-gray-200 px-3 py-2 dark:border-white/10"> <div className="text-xs font-semibold text-gray-900 dark:text-white">
<div className="flex items-center justify-between gap-3"> AI Rating
<div>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{tone.label} · {stars}/5 Sterne
</div>
</div>
<RatingStar stars={stars} className="h-7 w-7" />
</div>
</div> </div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
<div className="max-h-[420px] overflow-y-auto px-3 py-2"> {tone.label} · {stars}/5 Sterne
{entries.length > 0 ? (
<div className="space-y-1.5">
{entries.map((entry) => (
<div key={entry.key} className="flex items-start justify-between gap-3 text-[12px]">
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
title={entry.value}
>
{entry.value}
</span>
</div>
))}
</div>
) : null}
{segments.length > 0 ? (
<div className={entries.length > 0 ? 'mt-3 border-t border-gray-200 pt-2 dark:border-white/10' : ''}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Segmente
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
{segments.length}
</div>
</div>
<div className="space-y-2">
{segments.map((segment, index) => (
<button
key={`${segment.key}-${index}`}
type="button"
disabled={!onOpenAt || typeof segment.openAtSec !== 'number'}
title={
onOpenAt && typeof segment.openAtSec === 'number'
? `Video bei ${formatSeconds(segment.openAtSec)} öffnen`
: undefined
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!onOpenAt || typeof segment.openAtSec !== 'number') return
onOpenAt(segment.openAtSec)
}}
className={[
'relative block w-full overflow-hidden rounded-md text-left ring-1 transition',
onOpenAt && typeof segment.openAtSec === 'number'
? 'cursor-pointer hover:scale-[1.01] hover:ring-indigo-400/60 focus:outline-none focus:ring-2 focus:ring-indigo-500'
: 'cursor-default',
segment.preview
? 'bg-black ring-white/10'
: 'bg-gray-50 ring-gray-200 dark:bg-white/5 dark:ring-white/10',
].join(' ')}
>
{segment.preview ? (
<div className="absolute inset-0 bg-black" aria-hidden="true">
<div style={segment.preview.frameStyle}>
<img
src={segment.preview.src}
alt=""
draggable={false}
style={segment.preview.imageStyle}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/45 to-black/70" />
<div className="absolute inset-0 bg-black/20" />
</div>
) : null}
<div className="relative z-10 px-2.5 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div
className={[
'truncate text-[12px] font-semibold',
segment.preview ? 'text-white' : 'text-gray-900 dark:text-white',
].join(' ')}
>
{segment.title}
</div>
{segment.timeLabel ? (
<div
className={[
'mt-0.5 text-[11px] tabular-nums',
segment.preview ? 'text-white/75' : 'text-gray-500 dark:text-gray-400',
].join(' ')}
>
{segment.timeLabel}
</div>
) : null}
</div>
{segment.valueLabel ? (
<div
className={[
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
segment.preview
? 'bg-black/45 text-white ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-800 ring-gray-200 dark:bg-black/20 dark:text-gray-100 dark:ring-white/10',
].join(' ')}
>
{segment.valueLabel}
</div>
) : null}
</div>
{segment.detail ? (
<div
className={[
'mt-1.5 text-[11px] leading-snug',
segment.preview ? 'text-white/85' : 'text-gray-600 dark:text-gray-300',
].join(' ')}
>
{segment.detail}
</div>
) : null}
{segment.tags.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{segment.tags.map((tag) => (
<span
key={tag}
className={[
'rounded-full px-1.5 py-0.5 text-[10px] font-medium ring-1',
segment.preview
? 'bg-black/45 text-white/90 ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-600 ring-gray-200 dark:bg-black/20 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{tag}
</span>
))}
</div>
) : null}
</div>
</button>
))}
</div>
</div>
) : null}
</div> </div>
</div> </div>
)
} <RatingStar stars={stars} className="h-7 w-7" />
</div>
</div>
<div className="max-h-[520px] overflow-y-auto overscroll-contain p-3">
{entries.length > 0 ? (
<div className="mb-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="space-y-1.5">
{entries.map((entry) => (
<div
key={entry.key}
className="flex items-start justify-between gap-3 text-[12px]"
>
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
>
{entry.value}
</span>
</div>
))}
</div>
</div>
) : null}
{segments.length > 0 ? (
<RatingSegmentsPanel
segments={segments}
onOpenAt={onOpenAt}
maxHeight={360}
className="shadow-none"
/>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-4 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine interessanten Stellen vorhanden.
</div>
)}
</div>
</div>
)
// Mobile/Touch: kein HoverPopover, nur Bottom Sheet
if (touchLike) {
return (
<>
{starNode}
<RatingMobileSheet
open={tapOpen}
stars={stars}
toneLabel={tone.label}
entries={entries}
segments={segments}
onClose={() => setTapOpen(false)}
onOpenAt={onOpenAt}
/>
</>
)
}
// Desktop: Hover-Popover
return (
<HoverPopover
content={(open) => (open ? desktopPopoverContent : null)}
> >
{starNode} {starNode}
</HoverPopover> </HoverPopover>

View File

@ -42,6 +42,23 @@ type RecorderSettings = {
trainingDetectorEpochs?: number trainingDetectorEpochs?: number
} }
type AIServerStatus = {
ok: boolean
running: boolean
url?: string
statusCode?: number
error?: string
}
type AppLog = {
ok: boolean
exists?: boolean
path?: string
sizeBytes?: number
log?: string
error?: string
}
type DiskStatus = { type DiskStatus = {
emergency: boolean emergency: boolean
pauseGB: number pauseGB: number
@ -286,6 +303,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null) const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const [aiServerStatus, setAiServerStatus] = useState<AIServerStatus | null>(null)
const [appLog, setAppLog] = useState<AppLog | null>(null)
const [appLogLoading, setAppLogLoading] = useState(false)
const [appLogOpen, setAppLogOpen] = useState(false)
const [dbModalOpen, setDbModalOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('') const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
@ -647,6 +668,95 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
}, []) }, [])
useEffect(() => {
let alive = true
const load = async () => {
try {
const res = await fetch('/api/status/ai-server', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!alive) return
if (!res.ok || !data) {
setAiServerStatus({
ok: false,
running: false,
error: `HTTP ${res.status}`,
})
return
}
setAiServerStatus({
ok: Boolean(data.ok),
running: Boolean(data.running),
url: String(data.url || ''),
statusCode: Number(data.statusCode || 0),
error: String(data.error || ''),
})
} catch (e) {
if (!alive) return
setAiServerStatus({
ok: false,
running: false,
error: e instanceof Error ? e.message : String(e),
})
}
}
void load()
const timer = window.setInterval(load, 5000)
return () => {
alive = false
window.clearInterval(timer)
}
}, [])
async function loadAppLog() {
setAppLogLoading(true)
try {
const res = await fetch('/api/status/log?maxBytes=131072', {
cache: 'no-store',
})
const data = await res.json().catch(() => null)
if (!res.ok || !data) {
setAppLog({
ok: false,
log: '',
error: `HTTP ${res.status}`,
})
return
}
setAppLog({
ok: Boolean(data.ok),
exists: Boolean(data.exists),
path: String(data.path || ''),
sizeBytes: Number(data.sizeBytes || 0),
log: String(data.log || ''),
error: String(data.error || ''),
})
} catch (e) {
setAppLog({
ok: false,
log: '',
error: e instanceof Error ? e.message : String(e),
})
} finally {
setAppLogLoading(false)
}
}
useEffect(() => {
if (!appLogOpen) return
void loadAppLog()
}, [appLogOpen])
async function browse(target: 'record' | 'done' | 'ffmpeg') { async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -934,7 +1044,47 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
header={ header={
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div> <div className="flex flex-wrap items-center gap-2">
<div className="text-base font-semibold text-gray-900 dark:text-white">
Einstellungen
</div>
<span
className={[
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
aiServerStatus === null
? 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
: aiServerStatus.running
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30',
].join(' ')}
title={
aiServerStatus === null
? 'AI Server Status wird geladen…'
: aiServerStatus.running
? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}`
: `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}`
}
>
<span
className={[
'h-1.5 w-1.5 rounded-full',
aiServerStatus === null
? 'bg-gray-400'
: aiServerStatus.running
? 'bg-emerald-500'
: 'bg-red-500',
].join(' ')}
aria-hidden="true"
/>
{aiServerStatus === null
? 'AI prüft…'
: aiServerStatus.running
? 'AI online'
: 'AI offline'}
</span>
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks. Recorder-Konfiguration, Automatisierung und Tasks.
</div> </div>
@ -1400,16 +1550,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/> />
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center"> <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4"> <div className="sm:col-span-9">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200"> <div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Object Detection Epochs Object Detection Epochs
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-300"> <div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300">
Maximale Trainingsdauer. 60 ist ein guter Standard. Anzahl der Trainingsdurchläufe für die Box-Erkennung. Mehr Epochs können die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard.
</div> </div>
</div> </div>
<div className="sm:col-span-8"> <div className="sm:col-span-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<input <input
type="number" type="number"
@ -1526,6 +1676,83 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
)} )}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<button
type="button"
onClick={() => setAppLogOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Log
</div>
{appLog?.sizeBytes ? (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{Math.ceil(appLog.sizeBytes / 1024)} KB
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
</div>
</div>
<span
className={[
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
appLogOpen ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</button>
{appLogOpen ? (
<div className="border-t border-gray-200 p-4 dark:border-white/10">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
{appLog?.path ? (
<span className="break-all">
Logdatei: {appLog.path}
</span>
) : appLogLoading ? (
'Logdatei wird geladen…'
) : (
'Logdatei noch nicht geladen.'
)}
</div>
<Button
variant="secondary"
size="sm"
disabled={appLogLoading}
onClick={() => void loadAppLog()}
>
{appLogLoading ? 'Lade…' : 'Aktualisieren'}
</Button>
</div>
{appLog?.error ? (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
{appLog.error}
</div>
) : null}
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10">
{appLogLoading
? 'Log wird geladen…'
: appLog?.log?.trim()
? appLog.log
: 'Noch kein Log vorhanden.'}
</pre>
</div>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -449,14 +449,6 @@ function buildSegmentPreviewSrc(job: RecordJob | null, seg: Segment): string {
return `/api/preview?id=${encodeURIComponent(id)}&t=${encodeURIComponent(safeTime.toFixed(3))}` return `/api/preview?id=${encodeURIComponent(id)}&t=${encodeURIComponent(safeTime.toFixed(3))}`
} }
const AUTO_SELECTED_AI_LABELS = new Set([
'anus_exposed',
'female_genitalia_exposed',
'male_genitalia_exposed',
'female_breast_exposed',
'buttocks_exposed',
])
const AI_SEGMENT_PADDING_SECONDS = 3 const AI_SEGMENT_PADDING_SECONDS = 3
function pickFirstDefined<T = any>(...vals: T[]): T | undefined { function pickFirstDefined<T = any>(...vals: T[]): T | undefined {
@ -829,7 +821,15 @@ function normalizeAiSegmentsFromHits(
function shouldAutoSelectAiHit(hit: { function shouldAutoSelectAiHit(hit: {
label: string label: string
}): boolean { }): boolean {
return AUTO_SELECTED_AI_LABELS.has(String(hit.label || '').trim().toLowerCase()) const label = String(hit.label || '').trim().toLowerCase()
return (
label.startsWith('combo:') ||
label.startsWith('position:') ||
label.startsWith('object:') ||
label.startsWith('clothing:') ||
label.startsWith('body:')
)
} }
function segmentPairKey(aIndex: number, bIndex: number): string { function segmentPairKey(aIndex: number, bIndex: number): string {

View File

@ -7,16 +7,12 @@
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,