// backend\server.go package main import ( "bytes" "context" "crypto/rand" "encoding/hex" "fmt" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "sync" "syscall" "time" "github.com/joho/godotenv" ) var embeddedTLSOnce sync.Once var embeddedTLSCertPath string var embeddedTLSKeyPath string var embeddedTLSErr error func embeddedTLSPathsCached() (string, string, error) { embeddedTLSOnce.Do(func() { embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr = embeddedTLSFilesDir() }) return embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr } func escapePowerShell(s string) string { return strings.ReplaceAll(s, "'", "''") } 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, ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, // CREATE_NO_WINDOW } 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 } 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 { 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"), ) } 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 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, } 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 } 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 } 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) } appBaseDir := exeDir if strings.TrimSpace(appBaseDir) == "" { appBaseDir, _ = os.Getwd() } trainingRoot := filepath.Join(appBaseDir, "generated", "training") detectionLabelsPath := filepath.Join(trainingRoot, "detection_labels.json") defaultModelPath := filepath.Join(trainingRoot, "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", "0.0.0.0", "--port", port, "--log-level", "error", ) cmd := exec.CommandContext(ctx, 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.") // Immer App-Root/generated/training verwenden. env = upsertEnv(env, "APP_BASE_DIR", appBaseDir) env = upsertEnv(env, "TRAINING_ROOT", trainingRoot) env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath) // Modell ebenfalls standardmäßig aus generated/training nehmen, // aber YOLO_MODEL darf weiterhin extern überschrieben werden. if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" { 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) } 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" { 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 { _ = p.cmd.Process.Kill() } } func setEnvIfMissing(key, value string) { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" { return } if strings.TrimSpace(os.Getenv(key)) != "" { return } _ = os.Setenv(key, value) } func 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 loadEmbeddedDotEnv() { b, err := embeddedDotEnvBytes() if err != nil { appLogln("ℹ️ Keine eingebettete .env gefunden.") return } envMap, err := godotenv.Parse(bytes.NewReader(b)) if err != nil { appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err) return } for k, v := range envMap { setEnvIfMissing(k, v) } appLogln("✅ Eingebettete .env geladen") } func loadExternalDotEnvNextToExe() { exePath, err := os.Executable() if err != nil { if err := godotenv.Overload(); err == nil { appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis") } return } exeDir := filepath.Dir(exePath) envPath := filepath.Join(exeDir, ".env") if err := godotenv.Overload(envPath); err == nil { appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath) return } if err := godotenv.Overload(); err == nil { appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env") } } func loadAppEnv() { loadEmbeddedDotEnv() loadExternalDotEnvNextToExe() } func httpsEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED"))) return raw == "1" || raw == "true" || raw == "yes" || raw == "on" } func resolveMaybeRelativeToExe(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if filepath.IsAbs(path) { return path } exePath, err := os.Executable() if err != nil || exePath == "" { return path } return filepath.Join(filepath.Dir(exePath), path) } func tlsCertFile() string { raw := strings.TrimSpace(os.Getenv("TLS_CERT_FILE")) if raw != "" { return resolveMaybeRelativeToExe(raw) } certPath, _, err := embeddedTLSPathsCached() if err == nil && certPath != "" { return certPath } return resolveMaybeRelativeToExe("recorder-cert.pem") } func tlsKeyFile() string { raw := strings.TrimSpace(os.Getenv("TLS_KEY_FILE")) if raw != "" { return resolveMaybeRelativeToExe(raw) } _, keyPath, err := embeddedTLSPathsCached() if err == nil && keyPath != "" { return keyPath } return resolveMaybeRelativeToExe("recorder-key.pem") } func publicAppURL() string { scheme := "http" if httpsEnabled() { scheme = "https" } host := strings.TrimSpace(os.Getenv("APP_HOST")) if host == "" { host = "localhost" } port := strings.TrimSpace(os.Getenv("APP_PORT")) if port == "" { port = "9999" } return scheme + "://" + host + ":" + port } // --- main --- func main() { clearAppLog() initAppLog() defer closeAppLog() loadAppEnv() loadSettings() appCtx, appCancel := context.WithCancel(context.Background()) defer appCancel() // Nur eine Instanz erlauben: appLn, err := net.Listen("tcp", ":9999") if err != nil { _ = showAppNotification( "Recorder", "Die Anwendung wird bereits ausgeführt.", ) appLogln("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") os.Exit(0) } 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. resetAutostartPauseOnStartup() fixKeepRootFilesIntoModelSubdirs() postWorkQ.StartWorkers(2) enrichQ.StartWorkers(1) startPostWorkStatusRefresher() startEnrichStatusRefresher() startPostworkLeftoverScanOnStartup() go 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() os.Exit(1) } store := registerRoutes(mux, auth) setChaturbateOnlineModelStore(store) if err := clearAllPendingAutoStartOnStartup(); err != nil { appLogln("⚠️ [pending-autostart] startup clear failed:", err) } // Hintergrund-Worker go startDiskSpaceGuard() go startChaturbateOnlinePoller(store) go startChaturbateAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store) // optional: Bootstrap nur im Hintergrund if getSettings().UseChaturbateAPI { go 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:", publicAppURL()) appLogln("🔐 TLS Cert:", tlsCertFile()) appLogln("🔐 TLS Key: ", tlsKeyFile()) } else { appLogln("🌐 HTTP-API aktiv: http://localhost:9999") } handler := withCORS(mux) srv := &http.Server{ Addr: ":9999", Handler: handler, } var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { if aiProc != nil { aiProc.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) go func() { <-stopSig shutdown() }() serverErrCh := make(chan error, 1) go 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 }() go func() { _ = showAppNotification( "Recorder gestartet", buildStartupNotificationMessage(), ) }() go func() { err := <-serverErrCh if err != nil { appLogln("❌ HTTP-Server Fehler:", err) shutdown() } }() runTray(shutdown, buildTrayStats) shutdown() }