556 lines
12 KiB
Go
556 lines
12 KiB
Go
// backend\server.go
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
)
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
func aiServerURL() string {
|
||
raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL"))
|
||
if raw == "" {
|
||
raw = "http://127.0.0.1:8765"
|
||
}
|
||
|
||
return strings.TrimRight(raw, "/")
|
||
}
|
||
|
||
func aiServerPortFromURL() string {
|
||
url := aiServerURL()
|
||
|
||
if strings.Contains(url, ":8765") {
|
||
return "8765"
|
||
}
|
||
|
||
// Einfacher Fallback. Wenn du später andere Ports willst,
|
||
// setze AI_SERVER_PORT explizit.
|
||
port := strings.TrimSpace(os.Getenv("AI_SERVER_PORT"))
|
||
if port != "" {
|
||
return port
|
||
}
|
||
|
||
return "8765"
|
||
}
|
||
|
||
func aiServerPythonPath() string {
|
||
raw := strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON"))
|
||
if raw != "" {
|
||
return raw
|
||
}
|
||
|
||
// Windows py launcher zuerst versuchen.
|
||
if runtime.GOOS == "windows" {
|
||
if _, err := exec.LookPath("py"); err == nil {
|
||
return "py"
|
||
}
|
||
}
|
||
|
||
if _, err := exec.LookPath("python"); err == nil {
|
||
return "python"
|
||
}
|
||
|
||
if _, err := exec.LookPath("python3"); err == nil {
|
||
return "python3"
|
||
}
|
||
|
||
return "python"
|
||
}
|
||
|
||
func findAIServerScriptDir() (string, error) {
|
||
cwd, _ := os.Getwd()
|
||
|
||
exePath, _ := os.Executable()
|
||
exeDir := ""
|
||
if exePath != "" {
|
||
exeDir = filepath.Dir(exePath)
|
||
}
|
||
|
||
candidates := []string{
|
||
filepath.Join(cwd, "backend", "ai_server.py"),
|
||
filepath.Join(cwd, "ai_server.py"),
|
||
}
|
||
|
||
if exeDir != "" {
|
||
candidates = append(candidates,
|
||
filepath.Join(exeDir, "backend", "ai_server.py"),
|
||
filepath.Join(exeDir, "ai_server.py"),
|
||
)
|
||
}
|
||
|
||
appLogln("🔎 Suche ai_server.py")
|
||
appLogln(" cwd:", cwd)
|
||
appLogln(" exeDir:", exeDir)
|
||
|
||
for _, path := range candidates {
|
||
cleanPath := filepath.Clean(path)
|
||
appLogln(" prüfe:", cleanPath)
|
||
|
||
if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() {
|
||
appLogln("✅ ai_server.py gefunden:", cleanPath)
|
||
return filepath.Dir(cleanPath), nil
|
||
}
|
||
}
|
||
|
||
embeddedDir, err := embeddedAIServerDir()
|
||
if err != nil {
|
||
return "", appErrorf("embedded ai_server.py konnte nicht extrahiert werden: %w", err)
|
||
}
|
||
|
||
appLogln("✅ embedded ai_server.py extrahiert:", filepath.Join(embeddedDir, "ai_server.py"))
|
||
return embeddedDir, nil
|
||
}
|
||
|
||
func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error {
|
||
deadline := time.Now().Add(timeout)
|
||
client := &http.Client{
|
||
Timeout: 800 * time.Millisecond,
|
||
}
|
||
|
||
for {
|
||
if time.Now().After(deadline) {
|
||
return appErrorf("AI Server nicht rechtzeitig bereit")
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+"/health", nil)
|
||
if err == nil {
|
||
res, err := client.Do(req)
|
||
if err == nil {
|
||
_ = res.Body.Close()
|
||
|
||
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(300 * time.Millisecond):
|
||
}
|
||
}
|
||
}
|
||
|
||
func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
url := aiServerURL()
|
||
client := &http.Client{
|
||
Timeout: 900 * time.Millisecond,
|
||
}
|
||
|
||
status := map[string]any{
|
||
"ok": false,
|
||
"running": false,
|
||
"url": url,
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil)
|
||
if err != nil {
|
||
status["error"] = err.Error()
|
||
writeJSON(w, http.StatusOK, status)
|
||
return
|
||
}
|
||
|
||
res, err := client.Do(req)
|
||
if err != nil {
|
||
status["error"] = err.Error()
|
||
writeJSON(w, http.StatusOK, status)
|
||
return
|
||
}
|
||
defer res.Body.Close()
|
||
|
||
status["statusCode"] = res.StatusCode
|
||
|
||
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
||
status["ok"] = true
|
||
status["running"] = true
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, status)
|
||
}
|
||
|
||
func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||
if !aiServerAutostartEnabled() {
|
||
appLogln("ℹ️ AI Server Autostart deaktiviert.")
|
||
return nil, nil
|
||
}
|
||
|
||
scriptDir, err := findAIServerScriptDir()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
exePath, _ := os.Executable()
|
||
exeDir := ""
|
||
if exePath != "" {
|
||
exeDir = filepath.Dir(exePath)
|
||
}
|
||
|
||
defaultProdModel := filepath.Join(exeDir, "generated", "training", "detector", "model", "best.pt")
|
||
scriptDirModel := filepath.Join(scriptDir, "generated", "training", "detector", "model", "best.pt")
|
||
|
||
pythonPath := aiServerPythonPath()
|
||
port := aiServerPortFromURL()
|
||
|
||
args := []string{}
|
||
|
||
// Windows py launcher braucht meist -3.
|
||
if runtime.GOOS == "windows" && filepath.Base(strings.ToLower(pythonPath)) == "py" {
|
||
args = append(args, "-3")
|
||
}
|
||
|
||
args = append(args,
|
||
"-m", "uvicorn",
|
||
"ai_server:app",
|
||
"--host", "127.0.0.1",
|
||
"--port", port,
|
||
"--log-level", "warning",
|
||
)
|
||
|
||
cmd := exec.CommandContext(ctx, pythonPath, args...)
|
||
cmd.Dir = scriptDir
|
||
|
||
env := os.Environ()
|
||
env = append(env,
|
||
"PYTHONUNBUFFERED=1",
|
||
"AI_SERVER_URL="+aiServerURL(),
|
||
)
|
||
|
||
// Defaults nur setzen, wenn nicht schon extern gesetzt.
|
||
if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" {
|
||
if fi, err := os.Stat(scriptDirModel); err == nil && fi != nil && !fi.IsDir() {
|
||
env = append(env, "YOLO_MODEL="+scriptDirModel)
|
||
} else if fi, err := os.Stat(defaultProdModel); err == nil && fi != nil && !fi.IsDir() {
|
||
env = append(env, "YOLO_MODEL="+defaultProdModel)
|
||
}
|
||
}
|
||
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
|
||
env = append(env, "YOLO_IMGSZ=640")
|
||
}
|
||
if strings.TrimSpace(os.Getenv("YOLO_BATCH")) == "" {
|
||
env = append(env, "YOLO_BATCH=16")
|
||
}
|
||
if strings.TrimSpace(os.Getenv("YOLO_CONF")) == "" {
|
||
env = append(env, "YOLO_CONF=0.25")
|
||
}
|
||
|
||
cmd.Env = env
|
||
|
||
appLogf(
|
||
"--- AI Server Start --- python=%s scriptDir=%s url=%s port=%s args=%s",
|
||
pythonPath,
|
||
scriptDir,
|
||
aiServerURL(),
|
||
port,
|
||
strings.Join(args, " "),
|
||
)
|
||
|
||
logWriter := appLogWriter()
|
||
cmd.Stdout = logWriter
|
||
cmd.Stderr = logWriter
|
||
|
||
if runtime.GOOS == "windows" {
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
}
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
proc := &aiServerProcess{
|
||
cmd: cmd,
|
||
}
|
||
|
||
go func() {
|
||
err := cmd.Wait()
|
||
|
||
if err != nil && ctx.Err() == nil {
|
||
appLogf("⚠️ AI Server beendet: %v", err)
|
||
} else {
|
||
appLogln("AI Server beendet.")
|
||
}
|
||
}()
|
||
|
||
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
|
||
if err := waitForAIServer(waitCtx, aiServerURL(), 30*time.Second); err != nil {
|
||
appLogln("⚠️ AI Server noch nicht bereit:", err)
|
||
// Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen.
|
||
return proc, nil
|
||
}
|
||
|
||
appLogln("🧠 AI Server bereit:", aiServerURL())
|
||
return proc, nil
|
||
}
|
||
|
||
func (p *aiServerProcess) Stop() {
|
||
if p == nil {
|
||
return
|
||
}
|
||
|
||
if p.cmd != nil && p.cmd.Process != nil {
|
||
appLogln("🛑 Beende AI Server...")
|
||
_ = p.cmd.Process.Kill()
|
||
}
|
||
}
|
||
|
||
// --- main ---
|
||
func main() {
|
||
clearAppLog()
|
||
initAppLog()
|
||
defer closeAppLog()
|
||
|
||
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(1)
|
||
enrichQ.StartWorkers(1)
|
||
startPostWorkStatusRefresher()
|
||
startEnrichStatusRefresher()
|
||
|
||
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)
|
||
}
|
||
|
||
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() {
|
||
appLogln("🛑 Shutdown gestartet")
|
||
|
||
if aiProc != nil {
|
||
aiProc.Stop()
|
||
}
|
||
|
||
appCancel()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
_ = srv.Shutdown(ctx)
|
||
|
||
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() {
|
||
if err := srv.Serve(appLn); 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()
|
||
}
|