nsfwapp/backend/server.go
2026-05-05 14:05:56 +02:00

556 lines
12 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()
}