// backend\server.go package main import ( "context" "crypto/rand" "encoding/hex" "fmt" "net" "net/http" neturl "net/url" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "sync" "syscall" "time" "github.com/joho/godotenv" ) func escapePowerShell(s string) string { return strings.ReplaceAll(s, "'", "''") } func escapeAppleScript(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, `"`, `\"`) return s } func showAppNotification(title, message string) error { switch runtime.GOOS { case "windows": ps := fmt.Sprintf(` Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing $n = New-Object System.Windows.Forms.NotifyIcon $n.Icon = [System.Drawing.SystemIcons]::Information $n.BalloonTipTitle = '%s' $n.BalloonTipText = '%s' $n.Visible = $true $n.ShowBalloonTip(3000) Start-Sleep -Milliseconds 3500 $n.Dispose() `, escapePowerShell(title), escapePowerShell(message)) cmd := exec.Command( "powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-Command", ps, ) hideCommandWindow(cmd) return cmd.Run() case "darwin": return exec.Command( "osascript", "-e", fmt.Sprintf( `display notification "%s" with title "%s"`, escapeAppleScript(message), escapeAppleScript(title), ), ).Run() case "linux": return exec.Command("notify-send", title, message).Run() default: return nil } } func buildStartupNotificationMessage() string { return "Die App läuft im Hintergrund." } type aiServerProcess struct { cmd *exec.Cmd done chan struct{} mu sync.Mutex stopping bool } func (p *aiServerProcess) markStopping() { if p == nil { return } p.mu.Lock() p.stopping = true p.mu.Unlock() } func (p *aiServerProcess) isStopping() bool { if p == nil { return false } p.mu.Lock() defer p.mu.Unlock() return p.stopping } // Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen // Modell) sauber neu gestartet werden kann. var ( aiServerMu sync.Mutex aiServerCurrent *aiServerProcess aiServerCtx context.Context aiServerRoot string ) type aiServerLifecycleSnapshot struct { State string Since time.Time Message string } var aiServerLifecycle = struct { sync.Mutex state string since time.Time message string }{} func setAIServerLifecycle(state string, message string) { state = strings.TrimSpace(strings.ToLower(state)) message = strings.TrimSpace(message) if state == "" { state = "unknown" } aiServerLifecycle.Lock() defer aiServerLifecycle.Unlock() if aiServerLifecycle.state != state { aiServerLifecycle.since = time.Now().UTC() } else if aiServerLifecycle.since.IsZero() { aiServerLifecycle.since = time.Now().UTC() } aiServerLifecycle.state = state aiServerLifecycle.message = message } func getAIServerLifecycle() aiServerLifecycleSnapshot { aiServerLifecycle.Lock() defer aiServerLifecycle.Unlock() return aiServerLifecycleSnapshot{ State: aiServerLifecycle.state, Since: aiServerLifecycle.since, Message: aiServerLifecycle.message, } } func aiServerHasCurrentProcess() bool { aiServerMu.Lock() defer aiServerMu.Unlock() return aiServerCurrent != nil } 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 } } var aiServerTokenOnce sync.Once var aiServerTokenValue string func randomAISecretHex(bytesLen int) string { if bytesLen <= 0 { bytesLen = 32 } b := make([]byte, bytesLen) if _, err := rand.Read(b); err != nil { fallback := fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()) return hex.EncodeToString([]byte(fallback)) } return hex.EncodeToString(b) } func aiServerToken() string { aiServerTokenOnce.Do(func() { token := strings.TrimSpace(os.Getenv("AI_SERVER_TOKEN")) if token == "" { token = randomAISecretHex(32) _ = os.Setenv("AI_SERVER_TOKEN", token) appLogln("🔐 AI_SERVER_TOKEN automatisch erzeugt.") } aiServerTokenValue = token }) return aiServerTokenValue } func addAIServerAuth(req *http.Request) { if req == nil { return } token := strings.TrimSpace(aiServerToken()) if token == "" { return } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-AI-Server-Token", token) } 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 { rawURL := aiServerURL() if parsed, err := neturl.Parse(rawURL); err == nil && parsed != nil { if port := strings.TrimSpace(parsed.Port()); port != "" { return port } } // 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 } if venvPython := configuredMLPythonVenvPythonPath(); venvPython != "" { return venvPython } // 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 configuredMLPythonVenvPythonPath() string { if strings.TrimSpace(os.Getenv("NSFWAPP_SKIP_ML_SETUP")) == "1" { return "" } candidates := []string{} if venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV")); venvDir != "" { candidates = append(candidates, venvDir) } if strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP")) == "1" { candidates = append(candidates, defaultMLPythonVenvDir()) } seen := map[string]bool{} for _, dir := range candidates { path := mlPythonVenvPythonPath(dir) key := filepath.Clean(path) if path == "" || seen[key] { continue } seen[key] = true if info, err := os.Stat(path); err == nil && info != nil && !info.IsDir() { return path } } return "" } 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"), ) } for _, path := range candidates { cleanPath := filepath.Clean(path) 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 existingFile(path string) bool { path = strings.TrimSpace(path) if path == "" { return false } fi, err := os.Stat(path) return err == nil && fi != nil && !fi.IsDir() } func existingDir(path string) bool { path = strings.TrimSpace(path) if path == "" { return false } fi, err := os.Stat(path) return err == nil && fi != nil && fi.IsDir() } func parentDir(path string, levels int) string { path = filepath.Clean(strings.TrimSpace(path)) if path == "." || path == "" { return "" } for i := 0; i < levels; i++ { next := filepath.Dir(path) if next == path || next == "." || next == "" { break } path = next } return path } func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir string) { // 1) Explizite ENV gewinnt immer. if raw := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); raw != "" { root := filepath.Clean(raw) return root, parentDir(root, 2) } cwd, _ := os.Getwd() exePath, _ := os.Executable() exeDir := "" if exePath != "" { exeDir = filepath.Dir(exePath) } scriptDir = strings.TrimSpace(scriptDir) type candidate struct { root string base string } candidates := []candidate{} add := func(base string) { base = strings.TrimSpace(base) if base == "" { return } base = filepath.Clean(base) candidates = append(candidates, candidate{ root: filepath.Join(base, "generated", "training"), base: base, }, candidate{ root: filepath.Join(base, "backend", "generated", "training"), base: filepath.Join(base, "backend"), }, ) } // Wichtig für Entwicklung: // findAIServerScriptDir() findet meistens dein backend-Verzeichnis. if scriptDir != "" && !isTempBuildDir(scriptDir) { add(scriptDir) } // Falls du aus repo-root oder backend startest. add(cwd) // Wichtig für gebaute EXE. if exeDir != "" && !isTempBuildDir(exeDir) { add(exeDir) } // Bevorzugt den Pfad, wo detection_labels.json liegt. for _, c := range candidates { labels := filepath.Join(c.root, "detection_labels.json") if existingFile(labels) { return filepath.Clean(c.root), filepath.Clean(c.base) } } // Danach einen Pfad, wo zumindest der Training-Ordner existiert. for _, c := range candidates { if existingDir(c.root) { return filepath.Clean(c.root), filepath.Clean(c.base) } } // Fallback: // Wenn nichts existiert, nimm scriptDir/generated/training. if scriptDir != "" { root := filepath.Join(scriptDir, "generated", "training") return filepath.Clean(root), filepath.Clean(scriptDir) } root := filepath.Join(cwd, "generated", "training") return filepath.Clean(root), filepath.Clean(cwd) } 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 { addAIServerAuth(req) 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, } lifecycle := getAIServerLifecycle() state := strings.TrimSpace(strings.ToLower(lifecycle.State)) if state == "" || state == "unknown" { if aiServerHasCurrentProcess() { state = "starting" } else { state = "stopped" } } status := map[string]any{ "ok": false, "running": false, "url": url, "state": state, "starting": state == "starting", "restarting": state == "restarting", } if !lifecycle.Since.IsZero() { status["stateSince"] = lifecycle.Since.Format(time.RFC3339) } if strings.TrimSpace(lifecycle.Message) != "" { status["message"] = lifecycle.Message } if state == "restarting" && !lifecycle.Since.IsZero() && time.Since(lifecycle.Since) < 2*time.Minute { writeJSON(w, http.StatusOK, status) return } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil) if err != nil { status["error"] = err.Error() writeJSON(w, http.StatusOK, status) return } addAIServerAuth(req) 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 status["state"] = "running" status["starting"] = false status["restarting"] = false status["message"] = "AI Server läuft." setAIServerLifecycle("running", "AI Server läuft.") } writeJSON(w, http.StatusOK, status) } func aiServerRestartHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } current := getAIServerLifecycle() if strings.EqualFold(current.State, "restarting") && time.Since(current.Since) < 2*time.Minute { writeJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "state": "restarting", "restarting": true, "message": "AI Server Neustart läuft bereits.", }) return } setAIServerLifecycle("restarting", "AI Server wird neu gestartet…") appGo("ai-server-manual-restart", restartAIServer) writeJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "state": "restarting", "restarting": true, "message": "AI Server wird neu gestartet.", }) } func startAIServer(ctx context.Context) (*aiServerProcess, error) { if !aiServerAutostartEnabled() { appLogln("ℹ️ AI Server Autostart deaktiviert.") setAIServerLifecycle("stopped", "AI Server Autostart ist deaktiviert.") return nil, nil } if getAIServerLifecycle().State != "restarting" { setAIServerLifecycle("starting", "AI Server wird gestartet…") } if err := stopStaleAIServerOnPort(aiServerPortFromURL()); err != nil { appLogln("⚠️ Alter AI Server konnte nicht bereinigt werden:", err) } if err := ensureMLPythonSetup(ctx); err != nil { setAIServerLifecycle("error", "AI Server konnte nicht gestartet werden: "+err.Error()) return nil, err } scriptDir, err := findAIServerScriptDir() if err != nil { setAIServerLifecycle("error", "AI Server Skript wurde nicht gefunden: "+err.Error()) return nil, err } trainingRoot, appBaseDir := findTrainingRootDir(scriptDir) aiServerRoot = trainingRoot mlDir, mlErr := trainingEmbeddedMLDir() if mlErr != nil { appLogln("⚠️ embedded ML-Dateien konnten nicht extrahiert werden:", mlErr) } else { appLogln("✅ embedded ML-Dateien extrahiert:", mlDir) } detectionLabelsPath := strings.TrimSpace(os.Getenv("DETECTION_LABELS_PATH")) if detectionLabelsPath == "" { if mlErr == nil && strings.TrimSpace(mlDir) != "" { // ai_server.py soll die entpackte embedded detection_labels.json verwenden. detectionLabelsPath = filepath.Join(mlDir, "detection_labels.json") } else { // Fallback für Entwicklung / falls embedded extraction fehlschlägt. detectionLabelsPath = filepath.Join(trainingRoot, "detection_labels.json") } } else { detectionLabelsPath = filepath.Clean(detectionLabelsPath) } defaultModel := trainingResolveDetectorModel(trainingRoot) defaultModelPath := defaultModel.EffectivePath 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", "0.0.0.0", "--port", port, "--log-level", "error", ) cmd := exec.Command(pythonPath, args...) cmd.Dir = scriptDir env := os.Environ() env = append(env, "PYTHONUNBUFFERED=1", "AI_SERVER_URL="+aiServerURL(), ) env = upsertEnv(env, "AI_SERVER_AUTH_REQUIRED", "1") env = upsertEnv(env, "AI_SERVER_TOKEN", aiServerToken()) appLogln("🔐 AI Server Auth aktiv.") // Training-Root robust auflösen: // - Entwicklung: backend/generated/training // - Build/Release: exeDir/generated/training // - ENV TRAINING_ROOT / DETECTION_LABELS_PATH darf überschreiben. env = upsertEnv(env, "APP_BASE_DIR", appBaseDir) env = upsertEnv(env, "TRAINING_ROOT", trainingRoot) env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath) if strings.TrimSpace(os.Getenv("YOLO_BASE_POSE_MODEL")) == "" { if poseBase, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(poseBase) { env = upsertEnv(env, "YOLO_BASE_POSE_MODEL", poseBase) } else if err != nil { appLogln("⚠️ YOLO Base-Pose-Modell nicht gefunden:", err) } } // Modell ebenfalls standardmäßig aus generated/training nehmen, // aber YOLO_MODEL darf weiterhin extern überschrieben werden. if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" && defaultModel.EffectiveExists { env = upsertEnv(env, "YOLO_MODEL", defaultModelPath) } if fi, err := os.Stat(detectionLabelsPath); err != nil || fi == nil || fi.IsDir() { appLogln("⚠️ detection_labels.json nicht gefunden:", detectionLabelsPath) } else { appLogln("✅ detection_labels.json:", detectionLabelsPath) } if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() { appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath) } else { appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source) } 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 logWriter := appLogWriter() cmd.Stdout = logWriter cmd.Stderr = logWriter if runtime.GOOS == "windows" { hideCommandWindow(cmd) } prepareAIServerCommandForLifecycle(cmd) if ctx.Err() != nil { setAIServerLifecycle("stopped", "AI Server Start wurde abgebrochen.") return nil, ctx.Err() } if err := cmd.Start(); err != nil { setAIServerLifecycle("error", "AI Server konnte nicht gestartet werden: "+err.Error()) return nil, err } proc := &aiServerProcess{ cmd: cmd, done: make(chan struct{}), } appGo("ai-server-wait", func() { err := cmd.Wait() defer func() { if err != nil && ctx.Err() == nil && !proc.isStopping() { setAIServerLifecycle("error", "AI Server wurde beendet: "+err.Error()) return } if getAIServerLifecycle().State != "restarting" { setAIServerLifecycle("stopped", "AI Server wurde beendet.") } }() if err != nil && ctx.Err() == nil && !proc.isStopping() { appLogf("⚠️ AI Server beendet: %v", err) } else { appLogln("🛑 AI Server beendet.") } close(proc.done) }) if err := waitForAIServer(ctx, aiServerURL(), 120*time.Second); err != nil { appLogln("⚠️ AI Server noch nicht bereit:", err) // Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen. setAIServerLifecycle("starting", "AI Server startet noch: "+err.Error()) return proc, nil } appLogln("🧠 AI Server bereit:", aiServerURL()) setAIServerLifecycle("running", "AI Server läuft.") return proc, nil } func (p *aiServerProcess) Stop() { if p == nil { return } p.markStopping() terminateAIServerProcessTree(p.cmd) // Auf das tatsächliche Prozessende warten, damit der Port frei ist, // bevor ggf. ein neuer Server gestartet wird. if p.done != nil { select { case <-p.done: case <-time.After(10 * time.Second): appLogln("⚠️ AI Server reagiert nicht auf Stop (Timeout).") } } } // restartAIServer beendet den laufenden AI-Server und startet ihn frisch. // Wird z.B. nach einem erfolgreichen Training aufgerufen, damit das neue Modell // in einem sauberen Prozess geladen wird. func restartAIServer() { aiServerMu.Lock() defer aiServerMu.Unlock() ctx := aiServerCtx if ctx == nil { ctx = context.Background() } if ctx.Err() != nil { setAIServerLifecycle("stopped", "AI Server Neustart übersprungen, App wird beendet.") appLogln("ℹ️ AI Server Neustart übersprungen (App wird beendet).") return } appLogln("🔄 AI Server wird neu gestartet…") publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200) setAIServerLifecycle("restarting", "AI Server wird neu gestartet…") if aiServerCurrent != nil { aiServerCurrent.Stop() aiServerCurrent = nil } proc, err := startAIServer(ctx) if err != nil { setAIServerLifecycle("error", "AI Server Neustart fehlgeschlagen: "+err.Error()) appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000) return } aiServerCurrent = proc if proc != nil { setAIServerLifecycle("running", "AI Server wurde neu gestartet.") appLogln("✅ AI Server neu gestartet.") publishAppNotification("success", "AI Server", "AI Server wurde neu gestartet.", 2600) } } type trainingBestModelSnapshot struct { exists bool size int64 modTime time.Time } func snapshotTrainingBestModel(path string) trainingBestModelSnapshot { fi, err := os.Stat(path) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return trainingBestModelSnapshot{} } return trainingBestModelSnapshot{ exists: true, size: fi.Size(), modTime: fi.ModTime(), } } func (s trainingBestModelSnapshot) changed(next trainingBestModelSnapshot) bool { if s.exists != next.exists { return true } if !s.exists { return false } return s.size != next.size || !s.modTime.Equal(next.modTime) } func startTrainingBestModelWatcher(ctx context.Context, trainingRoot string) { trainingRoot = filepath.Clean(strings.TrimSpace(trainingRoot)) if trainingRoot == "" { return } paths := []string{ filepath.Join(trainingRoot, "detector", "model", "best.pt"), filepath.Join(trainingRoot, "pose", "model", "best.pt"), } snapshots := make(map[string]trainingBestModelSnapshot, len(paths)) for _, path := range paths { snapshots[path] = snapshotTrainingBestModel(path) } appLogln("👁️ Training-Model-Watcher aktiv:", strings.Join(paths, " | ")) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() var lastRestart time.Time for { select { case <-ctx.Done(): return case <-ticker.C: changedPaths := []string{} for _, path := range paths { previous := snapshots[path] next := snapshotTrainingBestModel(path) if previous.changed(next) { snapshots[path] = next if next.exists { changedPaths = append(changedPaths, path) } } } if len(changedPaths) == 0 { continue } if time.Since(lastRestart) < 5*time.Second { continue } lastRestart = time.Now() appLogln("🔄 Training-Modell geändert:", strings.Join(changedPaths, " | ")) appGo("ai-server-restart", restartAIServer) } } } func setEnvIfMissing(key, value string) { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" { return } if strings.TrimSpace(os.Getenv(key)) != "" { return } _ = os.Setenv(key, value) } func upsertEnv(env []string, key string, value string) []string { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" { return env } prefix := strings.ToUpper(key) + "=" out := make([]string, 0, len(env)+1) replaced := false for _, item := range env { if strings.HasPrefix(strings.ToUpper(item), prefix) { if !replaced && value != "" { out = append(out, key+"="+value) replaced = true } continue } out = append(out, item) } if !replaced && value != "" { out = append(out, key+"="+value) } return out } func loadExternalDotEnvNextToExe() { exePath, err := os.Executable() if err != nil { if err := godotenv.Overload(); err == nil { appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis") } return } exeDir := filepath.Dir(exePath) envPath := filepath.Join(exeDir, ".env") if err := godotenv.Overload(envPath); err == nil { appLogln("✅ Externe .env geladen:", envPath) return } if err := godotenv.Overload(); err == nil { appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis") } } func loadAppEnv() { loadExternalDotEnvNextToExe() } func httpsEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED"))) if raw != "" { return raw == "1" || raw == "true" || raw == "yes" || raw == "on" } return !isDevRuntime() } func isDevRuntime() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) switch raw { case "dev", "development", "local": return true case "prod", "production", "release": return false } if exePath, err := os.Executable(); err == nil { exePath = strings.ToLower(filepath.ToSlash(exePath)) if strings.Contains(exePath, "/go-build") { return true } exeDir := filepath.Dir(exePath) if existingFile(filepath.Join(exeDir, "go.mod")) && existingFile(filepath.Join(exeDir, "server.go")) { return true } return false } cwd, err := os.Getwd() if err != nil { return false } return existingFile(filepath.Join(cwd, "go.mod")) && existingFile(filepath.Join(cwd, "server.go")) } func resolveMaybeRelativeToExe(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if filepath.IsAbs(path) { return path } exePath, err := os.Executable() if err != nil || exePath == "" { return path } return filepath.Join(filepath.Dir(exePath), path) } func tlsCertFile() string { raw := strings.TrimSpace(os.Getenv("TLS_CERT_FILE")) if raw != "" { return resolveMaybeRelativeToExe(raw) } certPath, _, err := localTLSPathsCached() if err == nil && certPath != "" { return certPath } if err != nil { appLogln("⚠️ dynamisches TLS-Zertifikat konnte nicht erstellt werden:", err) } return "" } func tlsKeyFile() string { raw := strings.TrimSpace(os.Getenv("TLS_KEY_FILE")) if raw != "" { return resolveMaybeRelativeToExe(raw) } _, keyPath, err := localTLSPathsCached() if err == nil && keyPath != "" { return keyPath } if err != nil { appLogln("⚠️ dynamischer TLS-Key konnte nicht erstellt werden:", err) } return "" } func publicAppURL() string { urls := publicAppURLs() if len(urls) > 0 { return urls[0] } return "http://localhost:9999" } func publicAppURLs() []string { scheme := "http" if httpsEnabled() { scheme = "https" } port := strings.TrimSpace(os.Getenv("APP_PORT")) if port == "" { port = "9999" } out := make([]string, 0, 8) seen := map[string]bool{} addURL := func(raw string) { u, ok := canonicalOrigin(raw) if !ok || seen[u] { return } seen[u] = true out = append(out, u) } for _, u := range configuredPublicAppOrigins() { addURL(u) } for _, host := range localAppHostCandidates() { addURL(originForHost(scheme, host, port)) } return out } // --- main --- func main() { clearAppLog() initAppLog() defer func() { panicked := appRecoverPanic("main") closeAppLog() if panicked { os.Exit(2) } }() loadAppEnv() loadSettings() appCtx, appCancel := context.WithCancel(context.Background()) defer appCancel() initMLPythonSetupStatus() aiServerMu.Lock() aiServerCtx = appCtx aiServerCurrent = nil aiServerMu.Unlock() listenAddr := ":" + appPort() // Nur eine Instanz erlauben: appLn, err := net.Listen("tcp", listenAddr) if err != nil { _ = showAppNotification( "Recorder", "Die Anwendung wird bereits ausgeführt.", ) appLogln("⚠️ Die App läuft bereits oder Port " + appPort() + " ist bereits belegt.") return } defer appLn.Close() // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. resetAutostartPauseOnStartup() fixKeepRootFilesIntoModelSubdirs() postWorkQ.StartWorkers(2) enrichQ.StartWorkers(1) startPostWorkStatusRefresher() startEnrichStatusRefresher() startPostworkLeftoverScanOnStartup() appGo("generated-garbage-collector", startGeneratedGarbageCollector) // Altes NSFW-ONNX nicht mehr hart initialisieren. // Das neue Training-/YOLO-Modell wird über trainingPredictFrame genutzt. var closeNSFWOnce sync.Once closeNSFW := func() { closeNSFWOnce.Do(func() { _ = closeNSFWDetector() }) } defer closeNSFW() mux := http.NewServeMux() auth, err := NewAuthManager() if err != nil { appLogln("❌ auth init:", err) closeNSFW() return } store := registerRoutes(mux, auth) setChaturbateOnlineModelStore(store) if err := clearAllPendingAutoStartOnStartup(); err != nil { appLogln("⚠️ [pending-autostart] startup clear failed:", err) } // Hintergrund-Worker appGo("disk-space-guard", startDiskSpaceGuard) appGo("chaturbate-online-poller", func() { startChaturbateOnlinePoller(store) }) appGo("chaturbate-autostart-worker", func() { startChaturbateAutoStartWorker(store) }) appGo("myfreecams-autostart-worker", func() { startMyFreeCamsAutoStartWorker(store) }) // optional: Bootstrap nur im Hintergrund if getSettings().UseChaturbateAPI { appGo("chaturbate-api-bootstrap", func() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() if _, err := refreshChaturbateSnapshotNow(ctx); err != nil { appLogln("⚠️ Chaturbate API failed:", err) } else { appLogln("✅ Chaturbate API geladen") } }) } if _, err := ensureCoversDir(); err != nil { appLogln("⚠️ covers dir:", err) } if httpsEnabled() { appLogln("🌐 HTTPS-API aktiv:", strings.Join(publicAppURLs(), ", ")) appLogln("🔐 TLS Cert:", tlsCertFile()) appLogln("🔐 TLS Key: ", tlsKeyFile()) } else { appLogln("🌐 HTTP-API aktiv:", strings.Join(publicAppURLs(), ", ")) } handler := withCORS(mux) srv := &http.Server{ Addr: listenAddr, Handler: handler, } var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { aiServerMu.Lock() current := aiServerCurrent aiServerCurrent = nil aiServerMu.Unlock() if current != nil { current.Stop() } appCancel() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx) appLogln("🛑 Server beendet.") closeNSFW() }) } stopSig := make(chan os.Signal, 1) signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM) appGo("shutdown-signal-listener", func() { <-stopSig shutdown() }) serverErrCh := make(chan error, 1) appGo("http-server", func() { var err error if httpsEnabled() { err = srv.ServeTLS(appLn, tlsCertFile(), tlsKeyFile()) } else { err = srv.Serve(appLn) } if err != nil && err != http.ErrServerClosed { serverErrCh <- err return } serverErrCh <- nil }) appGo("ai-server-start", func() { aiProc, err := startAIServer(appCtx) if err != nil { appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) } if appCtx.Err() != nil { if aiProc != nil { aiProc.Stop() } return } aiServerMu.Lock() aiServerCurrent = aiProc watchedTrainingRoot := aiServerRoot aiServerMu.Unlock() appGo("training-best-model-watcher", func() { startTrainingBestModelWatcher(appCtx, watchedTrainingRoot) }) }) appGo("startup-notification", func() { _ = showAppNotification( "Recorder gestartet", buildStartupNotificationMessage(), ) }) appGo("http-server-error-listener", func() { err := <-serverErrCh if err != nil { appLogln("❌ HTTP-Server Fehler:", err) shutdown() } }) runTray(appCtx, shutdown, buildTrayStats) shutdown() }