1016 lines
22 KiB
Go
1016 lines
22 KiB
Go
// 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
|
||
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
|
||
)
|
||
|
||
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.
|
||
add(scriptDir)
|
||
|
||
// Falls du aus repo-root oder backend startest.
|
||
add(cwd)
|
||
|
||
// Wichtig für gebaute EXE.
|
||
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)
|
||
|
||
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)
|
||
}
|
||
|
||
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.")
|
||
|
||
// 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)
|
||
}
|
||
|
||
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,
|
||
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
|
||
}
|
||
|
||
if aiServerCurrent != nil {
|
||
appLogln("🔄 AI Server wird neu gestartet…")
|
||
aiServerCurrent.Stop()
|
||
aiServerCurrent = nil
|
||
}
|
||
|
||
proc, err := startAIServer(ctx)
|
||
if err != nil {
|
||
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
|
||
return
|
||
}
|
||
|
||
aiServerCurrent = proc
|
||
|
||
if proc != nil {
|
||
appLogln("✅ AI Server neu gestartet.")
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// 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
|
||
aiServerMu.Unlock()
|
||
|
||
// ✅ 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: https://l14pbbk95100006.tegdssd.de:9999")
|
||
}
|
||
|
||
handler := withCORS(mux)
|
||
srv := &http.Server{
|
||
Addr: ":9999",
|
||
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(shutdown, buildTrayStats)
|
||
shutdown()
|
||
}
|