// backend\server.go package main import ( "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, ) 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{} } // 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 ) 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 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, } 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 } 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.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.") // 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) // 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, "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) } if err := cmd.Start(); err != nil { return nil, err } proc := &aiServerProcess{ cmd: cmd, done: make(chan struct{}), } go func() { err := cmd.Wait() if err != nil && ctx.Err() == nil { appLogf("⚠️ AI Server beendet: %v", err) } else { appLogln("🛑 AI Server beendet.") } close(proc.done) }() 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() } // 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 { 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) if aiServerCurrent != nil { aiServerCurrent.Stop() aiServerCurrent = nil } proc, err := startAIServer(ctx) if err != nil { appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000) return } aiServerCurrent = proc if proc != nil { 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, " | ")) go 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 := 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 { 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 closeAppLog() loadAppEnv() loadSettings() appCtx, appCancel := context.WithCancel(context.Background()) defer appCancel() 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.") os.Exit(0) } defer appLn.Close() aiProc, err := startAIServer(appCtx) if err != nil { appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) } // Handle/Context global hinterlegen, damit der Server später neu gestartet // werden kann (z.B. nach einem neuen Modell). aiServerMu.Lock() aiServerCtx = appCtx aiServerCurrent = aiProc watchedTrainingRoot := aiServerRoot aiServerMu.Unlock() go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot) // ✅ 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:", 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) 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(appCtx, shutdown, buildTrayStats) shutdown() }