updated
This commit is contained in:
parent
fe83e30725
commit
93e16e7f85
@ -127,9 +127,11 @@ configure_ml_python_setup() {
|
|||||||
|
|
||||||
NSFWAPP_ML_SETUP=1
|
NSFWAPP_ML_SETUP=1
|
||||||
NSFWAPP_ML_REQUIREMENTS="$requirements_file"
|
NSFWAPP_ML_REQUIREMENTS="$requirements_file"
|
||||||
NSFWAPP_ML_VENV="${NSFWAPP_ML_VENV:-$app_dir/ml-venv}"
|
ml_tmp_root="${TMPDIR:-/tmp}"
|
||||||
NSFWAPP_ML_SETUP_LOG="${NSFWAPP_ML_SETUP_LOG:-$app_dir/ml-setup.log}"
|
ml_tmp_user="${USER:-$(id -un 2>/dev/null || echo user)}"
|
||||||
export NSFWAPP_ML_SETUP NSFWAPP_ML_REQUIREMENTS NSFWAPP_ML_VENV NSFWAPP_ML_SETUP_LOG
|
ml_tmp_user="$(printf '%s' "$ml_tmp_user" | tr -c 'A-Za-z0-9_.-' '_')"
|
||||||
|
NSFWAPP_ML_VENV="${NSFWAPP_ML_VENV:-$ml_tmp_root/nsfwapp-ml-venv-$ml_tmp_user}"
|
||||||
|
export NSFWAPP_ML_SETUP NSFWAPP_ML_REQUIREMENTS NSFWAPP_ML_VENV
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,8 +290,8 @@ User-Daten:
|
|||||||
Wichtige Laufzeitprogramme:
|
Wichtige Laufzeitprogramme:
|
||||||
ffmpeg/ffprobe muessen fuer Recording, Teaser, Analyse und Videofunktionen verfuegbar sein.
|
ffmpeg/ffprobe muessen fuer Recording, Teaser, Analyse und Videofunktionen verfuegbar sein.
|
||||||
PostgreSQL ist die Datenbank. Fuer lokale Datenordner werden initdb/pg_ctl und fuer Migration/Backup pg_dump/pg_restore benoetigt.
|
PostgreSQL ist die Datenbank. Fuer lokale Datenordner werden initdb/pg_ctl und fuer Migration/Backup pg_dump/pg_restore benoetigt.
|
||||||
Der Launcher erstellt beim ersten Start automatisch ${NSFWAPP_HOME:-$HOME/nsfwapp}/ml-venv
|
Der Launcher erstellt beim ersten Start automatisch eine ML-Python-Umgebung unter ${TMPDIR:-/tmp}/nsfwapp-ml-venv-<user>
|
||||||
und installiert die mitgelieferte requirements-ml.txt. Details stehen in ml-setup.log.
|
und installiert die mitgelieferte requirements-ml.txt. Details stehen in recorder.log.
|
||||||
Setze AI_SERVER_PYTHON, wenn du eine eigene Python-Umgebung verwenden willst.
|
Setze AI_SERVER_PYTHON, wenn du eine eigene Python-Umgebung verwenden willst.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -23,7 +23,6 @@ type mlPythonSetupStatus struct {
|
|||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
LogFile string `json:"logFile,omitempty"`
|
|
||||||
StartedAt string `json:"startedAt,omitempty"`
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
FinishedAt string `json:"finishedAt,omitempty"`
|
FinishedAt string `json:"finishedAt,omitempty"`
|
||||||
@ -31,11 +30,11 @@ type mlPythonSetupStatus struct {
|
|||||||
|
|
||||||
type mlPythonSetupConfig struct {
|
type mlPythonSetupConfig struct {
|
||||||
enabled bool
|
enabled bool
|
||||||
|
appDir string
|
||||||
requirementsPath string
|
requirementsPath string
|
||||||
venvDir string
|
venvDir string
|
||||||
venvPython string
|
venvPython string
|
||||||
requirementsMark string
|
requirementsMark string
|
||||||
logPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -60,7 +59,6 @@ func initMLPythonSetupStatus() {
|
|||||||
Progress: 0,
|
Progress: 0,
|
||||||
Phase: "pending",
|
Phase: "pending",
|
||||||
Message: "ML-Python-Umgebung wartet auf Start.",
|
Message: "ML-Python-Umgebung wartet auf Start.",
|
||||||
LogFile: filepath.Base(cfg.logPath),
|
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -106,24 +104,58 @@ func mlPythonSetupConfigFromEnv() mlPythonSetupConfig {
|
|||||||
|
|
||||||
venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV"))
|
venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV"))
|
||||||
if venvDir == "" {
|
if venvDir == "" {
|
||||||
venvDir = filepath.Join(appDir, "ml-venv")
|
venvDir = defaultMLPythonVenvDir()
|
||||||
}
|
|
||||||
|
|
||||||
logPath := strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP_LOG"))
|
|
||||||
if logPath == "" {
|
|
||||||
logPath = filepath.Join(appDir, "ml-setup.log")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mlPythonSetupConfig{
|
return mlPythonSetupConfig{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
appDir: filepath.Clean(appDir),
|
||||||
requirementsPath: filepath.Clean(requirementsPath),
|
requirementsPath: filepath.Clean(requirementsPath),
|
||||||
venvDir: filepath.Clean(venvDir),
|
venvDir: filepath.Clean(venvDir),
|
||||||
venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"),
|
venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"),
|
||||||
requirementsMark: filepath.Join(filepath.Clean(venvDir), ".requirements-ml.txt"),
|
requirementsMark: filepath.Join(filepath.Clean(venvDir), ".requirements-ml.txt"),
|
||||||
logPath: filepath.Clean(logPath),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultMLPythonVenvDir() string {
|
||||||
|
base := strings.TrimSpace(os.TempDir())
|
||||||
|
if base == "" {
|
||||||
|
base = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
user := strings.TrimSpace(os.Getenv("USER"))
|
||||||
|
if user == "" {
|
||||||
|
user = strings.TrimSpace(os.Getenv("USERNAME"))
|
||||||
|
}
|
||||||
|
suffix := "nsfwapp-ml-venv"
|
||||||
|
if user != "" {
|
||||||
|
suffix += "-" + sanitizeMLPythonTempName(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(base, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeMLPythonTempName(raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.TrimSpace(raw) {
|
||||||
|
if (r >= 'a' && r <= 'z') ||
|
||||||
|
(r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') ||
|
||||||
|
r == '_' ||
|
||||||
|
r == '-' ||
|
||||||
|
r == '.' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else if b.Len() > 0 {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "_-.")
|
||||||
|
if out == "" {
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func mlPythonSetupStatusSnapshot() mlPythonSetupStatus {
|
func mlPythonSetupStatusSnapshot() mlPythonSetupStatus {
|
||||||
mlSetupMu.RLock()
|
mlSetupMu.RLock()
|
||||||
defer mlSetupMu.RUnlock()
|
defer mlSetupMu.RUnlock()
|
||||||
@ -171,7 +203,6 @@ func ensureMLPythonSetup(ctx context.Context) error {
|
|||||||
s.Phase = "prepare"
|
s.Phase = "prepare"
|
||||||
s.Message = "ML-Python-Umgebung wird vorbereitet."
|
s.Message = "ML-Python-Umgebung wird vorbereitet."
|
||||||
s.Error = ""
|
s.Error = ""
|
||||||
s.LogFile = filepath.Base(cfg.logPath)
|
|
||||||
s.StartedAt = startedAt
|
s.StartedAt = startedAt
|
||||||
s.FinishedAt = ""
|
s.FinishedAt = ""
|
||||||
})
|
})
|
||||||
@ -184,9 +215,6 @@ func ensureMLPythonSetup(ctx context.Context) error {
|
|||||||
if err := os.MkdirAll(cfg.venvDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.venvDir, 0755); err != nil {
|
||||||
return finishMLPythonSetupError("ML-Venv-Ordner konnte nicht erstellt werden.", err)
|
return finishMLPythonSetupError("ML-Venv-Ordner konnte nicht erstellt werden.", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(cfg.logPath), 0755); err != nil {
|
|
||||||
return finishMLPythonSetupError("ML-Setup-Logordner konnte nicht erstellt werden.", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if current, err := os.ReadFile(cfg.requirementsMark); err == nil && string(current) == string(reqBytes) {
|
if current, err := os.ReadFile(cfg.requirementsMark); err == nil && string(current) == string(reqBytes) {
|
||||||
if fi, statErr := os.Stat(cfg.venvPython); statErr == nil && fi != nil && !fi.IsDir() {
|
if fi, statErr := os.Stat(cfg.venvPython); statErr == nil && fi != nil && !fi.IsDir() {
|
||||||
@ -231,16 +259,10 @@ func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase
|
|||||||
s.Message = message
|
s.Message = message
|
||||||
})
|
})
|
||||||
|
|
||||||
logFile, err := os.OpenFile(cfg.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
appLogln("[ml-setup] start:", name, strings.Join(args, " "))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer logFile.Close()
|
|
||||||
|
|
||||||
_, _ = logFile.WriteString("\n[" + time.Now().Format(time.RFC3339) + "] " + name + " " + strings.Join(args, " ") + "\n")
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
cmd.Dir = filepath.Dir(cfg.logPath)
|
cmd.Dir = cfg.appDir
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -263,7 +285,7 @@ func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase
|
|||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
_, _ = logFile.WriteString(line + "\n")
|
appLogln("[ml-setup]", line)
|
||||||
select {
|
select {
|
||||||
case lines <- line:
|
case lines <- line:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@ -297,10 +319,14 @@ func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase
|
|||||||
}
|
}
|
||||||
lineCount++
|
lineCount++
|
||||||
progress := startProgress + minInt(progressRange-1, lineCount)
|
progress := startProgress + minInt(progressRange-1, lineCount)
|
||||||
|
visibleMessage := line
|
||||||
|
if phase == "requirements" {
|
||||||
|
visibleMessage = message
|
||||||
|
}
|
||||||
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
s.Progress = progress
|
s.Progress = progress
|
||||||
s.Phase = phase
|
s.Phase = phase
|
||||||
s.Message = line
|
s.Message = visibleMessage
|
||||||
})
|
})
|
||||||
|
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -17,7 +18,9 @@ const (
|
|||||||
postgresModeURL = "url"
|
postgresModeURL = "url"
|
||||||
postgresModeLocal = "local"
|
postgresModeLocal = "local"
|
||||||
|
|
||||||
defaultPostgresDataDir = "postgres-data"
|
postgresDataDirName = "nsfwapp-postgres-data"
|
||||||
|
legacyPostgresDataDir = "postgres-data"
|
||||||
|
defaultPostgresDataDir = postgresDataDirName
|
||||||
defaultPostgresPort = 55432
|
defaultPostgresPort = 55432
|
||||||
defaultPostgresDatabase = "nsfwapp"
|
defaultPostgresDatabase = "nsfwapp"
|
||||||
defaultPostgresUser = "nsfwapp"
|
defaultPostgresUser = "nsfwapp"
|
||||||
@ -39,7 +42,20 @@ func normalizePostgresDataDir(raw string) string {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
value = defaultPostgresDataDir
|
value = defaultPostgresDataDir
|
||||||
}
|
}
|
||||||
return filepath.Clean(filepath.FromSlash(value))
|
value = filepath.Clean(filepath.FromSlash(value))
|
||||||
|
|
||||||
|
base := filepath.Base(value)
|
||||||
|
if strings.EqualFold(base, postgresDataDirName) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if strings.EqualFold(base, legacyPostgresDataDir) {
|
||||||
|
parent := filepath.Dir(value)
|
||||||
|
if parent == "." || parent == string(filepath.Separator) {
|
||||||
|
return postgresDataDirName
|
||||||
|
}
|
||||||
|
return filepath.Join(parent, postgresDataDirName)
|
||||||
|
}
|
||||||
|
return filepath.Join(value, postgresDataDirName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePostgresPort(port int) int {
|
func normalizePostgresPort(port int) int {
|
||||||
@ -106,7 +122,7 @@ func postgresSettingsKey(s RecorderSettings) string {
|
|||||||
|
|
||||||
func buildLocalPostgresDSN(s RecorderSettings) (string, error) {
|
func buildLocalPostgresDSN(s RecorderSettings) (string, error) {
|
||||||
normalizePostgresSettings(&s)
|
normalizePostgresSettings(&s)
|
||||||
dataDir, err := resolvePathRelativeToApp(s.PostgresDataDir)
|
dataDir, err := localPostgresDataDirAbs(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -139,7 +155,7 @@ func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDir, err := resolvePathRelativeToApp(s.PostgresDataDir)
|
dataDir, err := localPostgresDataDirAbs(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -149,6 +165,11 @@ func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) {
|
|||||||
return dataDir, nil
|
return dataDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func localPostgresDataDirAbs(s RecorderSettings) (string, error) {
|
||||||
|
normalizePostgresSettings(&s)
|
||||||
|
return resolvePathRelativeToApp(s.PostgresDataDir)
|
||||||
|
}
|
||||||
|
|
||||||
func ensureLocalPostgresDataDirPath(dataDir string) error {
|
func ensureLocalPostgresDataDirPath(dataDir string) error {
|
||||||
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
@ -167,6 +188,209 @@ func ensureLocalPostgresDataDirPath(dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moveLocalPostgresDataDirIfNeeded(current, next RecorderSettings) (bool, error) {
|
||||||
|
normalizePostgresSettings(¤t)
|
||||||
|
normalizePostgresSettings(&next)
|
||||||
|
if current.PostgresMode != postgresModeLocal || next.PostgresMode != postgresModeLocal {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldDir, err := localPostgresDataDirAbs(current)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
newDir, err := localPostgresDataDirAbs(next)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
oldDir = filepath.Clean(oldDir)
|
||||||
|
newDir = filepath.Clean(newDir)
|
||||||
|
if samePath(oldDir, newDir) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHasCluster, err := localPostgresDataDirHasCluster(oldDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !oldHasCluster {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newHasCluster, err := localPostgresDataDirHasCluster(newDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if newHasCluster {
|
||||||
|
return false, appErrorf("Ziel enthält bereits einen PostgreSQL-Datenordner: %s", newDir)
|
||||||
|
}
|
||||||
|
if exists, empty, err := dirExistsEmpty(newDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if exists && !empty {
|
||||||
|
return false, appErrorf("Zielordner ist nicht leer: %s", newDir)
|
||||||
|
} else if exists && empty {
|
||||||
|
if err := os.Remove(newDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogln("PostgreSQL-Datenordner wird verschoben:", oldDir, "->", newDir)
|
||||||
|
if store := getModelStore(); store != nil {
|
||||||
|
_ = store.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := stopLocalPostgresCluster(ctx, oldDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := moveDir(oldDir, newDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
appLogln("PostgreSQL-Datenordner verschoben:", newDir)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func localPostgresDataDirHasCluster(dataDir string) (bool, error) {
|
||||||
|
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||||
|
if dataDir == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
versionFile := filepath.Join(dataDir, "PG_VERSION")
|
||||||
|
info, err := os.Stat(versionFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return info != nil && !info.IsDir(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirExistsEmpty(path string) (bool, bool, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return true, false, appErrorf("Ziel ist kein Ordner: %s", path)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return true, false, err
|
||||||
|
}
|
||||||
|
return true, len(entries) == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func samePath(a, b string) bool {
|
||||||
|
aa, errA := filepath.Abs(filepath.Clean(a))
|
||||||
|
bb, errB := filepath.Abs(filepath.Clean(b))
|
||||||
|
if errA == nil {
|
||||||
|
a = aa
|
||||||
|
}
|
||||||
|
if errB == nil {
|
||||||
|
b = bb
|
||||||
|
}
|
||||||
|
if strings.EqualFold(a, b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopLocalPostgresCluster(ctx context.Context, dataDir string) error {
|
||||||
|
pgCtl, err := postgresToolPath("pg_ctl")
|
||||||
|
if err != nil {
|
||||||
|
return appErrorf("pg_ctl wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm pg_ctl in PATH auf.")
|
||||||
|
}
|
||||||
|
out, err := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "-w", "stop", "-m", "fast")
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(out)
|
||||||
|
if strings.Contains(lower, "no server running") || strings.Contains(lower, "pid file") && strings.Contains(lower, "does not exist") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return appErrorf("lokaler PostgreSQL-Server konnte nicht gestoppt werden: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveDir(src, dst string) error {
|
||||||
|
src = filepath.Clean(src)
|
||||||
|
dst = filepath.Clean(dst)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(src, dst); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := dst + ".moving-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
if err := copyDir(src, tmp); err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, dst); err != nil {
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
src = filepath.Clean(src)
|
||||||
|
dst = filepath.Clean(dst)
|
||||||
|
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := info.Mode()
|
||||||
|
if entry.IsDir() {
|
||||||
|
return os.MkdirAll(target, mode.Perm())
|
||||||
|
}
|
||||||
|
if mode&os.ModeSymlink != 0 {
|
||||||
|
linkTarget, err := os.Readlink(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Symlink(linkTarget, target)
|
||||||
|
}
|
||||||
|
return copyRegularFile(path, target, mode.Perm())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyRegularFile(src, dst string, perm os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func ensureLocalPostgresCluster(ctx context.Context, dataDir string, port int, user, database string) error {
|
func ensureLocalPostgresCluster(ctx context.Context, dataDir string, port int, user, database string) error {
|
||||||
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||||
if err := ensureLocalPostgresDataDirPath(dataDir); err != nil {
|
if err := ensureLocalPostgresDataDirPath(dataDir); err != nil {
|
||||||
|
|||||||
@ -495,12 +495,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if in.PostgresMode == postgresModeLocal {
|
|
||||||
if _, err := ensureLocalPostgresDataDir(in.RecorderSettings); err != nil {
|
|
||||||
http.Error(w, "konnte PostgreSQL-Datenordner nicht erstellen: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
|
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
|
||||||
// und URL ohne Passwort zurückschreiben.
|
// und URL ohne Passwort zurückschreiben.
|
||||||
@ -583,11 +577,24 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
||||||
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
||||||
|
|
||||||
|
postgresDataMoved, err := moveLocalPostgresDataDirIfNeeded(current, next)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "PostgreSQL-Datenordner konnte nicht verschoben werden: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next.PostgresMode == postgresModeLocal && !postgresDataMoved {
|
||||||
|
if _, err := ensureLocalPostgresDataDir(next); err != nil {
|
||||||
|
http.Error(w, "konnte PostgreSQL-Datenordner nicht erstellen: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dbChanged :=
|
dbChanged :=
|
||||||
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
||||||
postgresSettingsKey(next) != postgresSettingsKey(current) ||
|
postgresSettingsKey(next) != postgresSettingsKey(current) ||
|
||||||
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
|
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
|
||||||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
|
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) ||
|
||||||
|
postgresDataMoved
|
||||||
|
|
||||||
var newStore *ModelStore
|
var newStore *ModelStore
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
current := getSettings()
|
current := getSettings()
|
||||||
|
normalizePostgresSettings(¤t)
|
||||||
target := current
|
target := current
|
||||||
target.DatabaseType = databaseTypePostgres
|
target.DatabaseType = databaseTypePostgres
|
||||||
target.PostgresMode = in.PostgresMode
|
target.PostgresMode = in.PostgresMode
|
||||||
@ -40,6 +41,50 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if current.PostgresMode == postgresModeLocal && target.PostgresMode == postgresModeLocal {
|
||||||
|
moved, err := moveLocalPostgresDataDirIfNeeded(current, target)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "PostgreSQL-Datenordner konnte nicht verschoben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
if _, err := ensureLocalPostgresDataDir(target); err != nil {
|
||||||
|
http.Error(w, "konnte PostgreSQL-Datenordner nicht erstellen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDSN, err := buildPostgresDSNFromRecorderSettings(target)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: targetDSN})
|
||||||
|
if err := newStore.Load(); err != nil {
|
||||||
|
_ = newStore.Close()
|
||||||
|
http.Error(w, "Ziel-Datenbank konnte nach Verschieben nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsMu.Lock()
|
||||||
|
settings = target
|
||||||
|
settingsMu.Unlock()
|
||||||
|
saveSettingsToDisk()
|
||||||
|
|
||||||
|
setModelStore(newStore)
|
||||||
|
setCoverModelStore(newStore)
|
||||||
|
setChaturbateOnlineModelStore(newStore)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"bytes": 0,
|
||||||
|
"mode": target.PostgresMode,
|
||||||
|
"moved": moved,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(target.DatabaseURL)
|
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(target.DatabaseURL)
|
||||||
pwFromURL = strings.TrimSpace(pwFromURL)
|
pwFromURL = strings.TrimSpace(pwFromURL)
|
||||||
if pwFromURL == "****" {
|
if pwFromURL == "****" {
|
||||||
|
|||||||
@ -50,7 +50,6 @@ type MLSetupStatus = {
|
|||||||
phase?: string
|
phase?: string
|
||||||
message?: string
|
message?: string
|
||||||
error?: string
|
error?: string
|
||||||
logFile?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetupResp = {
|
type SetupResp = {
|
||||||
@ -79,12 +78,14 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
|||||||
const title = failed
|
const title = failed
|
||||||
? 'ML-Python-Setup fehlgeschlagen'
|
? 'ML-Python-Setup fehlgeschlagen'
|
||||||
: 'ML-Python-Pakete werden installiert'
|
: 'ML-Python-Pakete werden installiert'
|
||||||
const message = String(status.message || '').trim() || 'Die ML-Python-Umgebung wird vorbereitet.'
|
const rawMessage = String(status.message || '').trim()
|
||||||
|
const message =
|
||||||
|
!failed && status.phase === 'requirements'
|
||||||
|
? 'ML-Python-Pakete werden installiert. Details stehen in recorder.log.'
|
||||||
|
: rawMessage || 'Die ML-Python-Umgebung wird vorbereitet.'
|
||||||
const detail = failed
|
const detail = failed
|
||||||
? String(status.error || '').trim()
|
? String(status.error || '').trim()
|
||||||
: status.logFile
|
: ''
|
||||||
? `Log: ${status.logFile}`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -94,7 +95,7 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-full rounded-xl border p-4 shadow-lg',
|
'w-full max-w-full overflow-hidden rounded-xl border p-4 shadow-lg',
|
||||||
failed
|
failed
|
||||||
? 'border-red-200 bg-red-50 text-red-950 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-100'
|
? 'border-red-200 bg-red-50 text-red-950 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-100'
|
||||||
: 'border-indigo-200 bg-white text-gray-900 dark:border-indigo-400/30 dark:bg-gray-900 dark:text-gray-100',
|
: 'border-indigo-200 bg-white text-gray-900 dark:border-indigo-400/30 dark:bg-gray-900 dark:text-gray-100',
|
||||||
@ -103,7 +104,9 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold">{title}</div>
|
<div className="text-sm font-semibold">{title}</div>
|
||||||
<div className="mt-1 break-words text-xs opacity-80">{message}</div>
|
<div className="mt-1 max-h-10 overflow-hidden break-words text-xs opacity-80">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-xs font-semibold tabular-nums">
|
<div className="shrink-0 text-xs font-semibold tabular-nums">
|
||||||
{Math.round(progress)}%
|
{Math.round(progress)}%
|
||||||
|
|||||||
@ -284,10 +284,13 @@ function SettingsSection(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POSTGRES_DATA_DIR_NAME = 'nsfwapp-postgres-data'
|
||||||
|
const LEGACY_POSTGRES_DATA_DIR_NAME = 'postgres-data'
|
||||||
|
|
||||||
const DEFAULTS: RecorderSettings = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
databaseType: 'postgres',
|
databaseType: 'postgres',
|
||||||
postgresMode: 'local',
|
postgresMode: 'local',
|
||||||
postgresDataDir: 'postgres-data',
|
postgresDataDir: POSTGRES_DATA_DIR_NAME,
|
||||||
postgresPort: 55432,
|
postgresPort: 55432,
|
||||||
postgresDatabase: 'nsfwapp',
|
postgresDatabase: 'nsfwapp',
|
||||||
postgresUser: 'nsfwapp',
|
postgresUser: 'nsfwapp',
|
||||||
@ -558,7 +561,21 @@ function normalizePostgresMode(value: unknown, databaseUrl?: unknown): 'url' | '
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePostgresDataDir(value: unknown) {
|
function normalizePostgresDataDir(value: unknown) {
|
||||||
return String(value ?? '').trim() || 'postgres-data'
|
let s = String(value ?? '').trim()
|
||||||
|
if (!s) return POSTGRES_DATA_DIR_NAME
|
||||||
|
|
||||||
|
s = s.replace(/[\\/]+$/g, '')
|
||||||
|
if (!s) return POSTGRES_DATA_DIR_NAME
|
||||||
|
|
||||||
|
const sep = s.includes('\\') && !s.includes('/') ? '\\' : '/'
|
||||||
|
const parts = s.split(/[\\/]+/)
|
||||||
|
const base = parts[parts.length - 1] || ''
|
||||||
|
if (base.toLowerCase() === POSTGRES_DATA_DIR_NAME.toLowerCase()) return s
|
||||||
|
if (base.toLowerCase() === LEGACY_POSTGRES_DATA_DIR_NAME.toLowerCase()) {
|
||||||
|
const parent = parts.slice(0, -1).join(sep)
|
||||||
|
return parent ? `${parent}${sep}${POSTGRES_DATA_DIR_NAME}` : POSTGRES_DATA_DIR_NAME
|
||||||
|
}
|
||||||
|
return `${s}${sep}${POSTGRES_DATA_DIR_NAME}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePostgresPort(value: unknown) {
|
function normalizePostgresPort(value: unknown) {
|
||||||
@ -737,7 +754,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const [dbModalOpen, setDbModalOpen] = useState(false)
|
const [dbModalOpen, setDbModalOpen] = useState(false)
|
||||||
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
|
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
|
||||||
const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('local')
|
const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('local')
|
||||||
const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState('postgres-data')
|
const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState(POSTGRES_DATA_DIR_NAME)
|
||||||
const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432)
|
const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432)
|
||||||
const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
|
const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
|
||||||
const [loadedPostgresUser, setLoadedPostgresUser] = useState('nsfwapp')
|
const [loadedPostgresUser, setLoadedPostgresUser] = useState('nsfwapp')
|
||||||
@ -1318,7 +1335,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setValue((v) => {
|
setValue((v) => {
|
||||||
if (target === 'record') return { ...v, recordDir: p }
|
if (target === 'record') return { ...v, recordDir: p }
|
||||||
if (target === 'done') return { ...v, doneDir: p }
|
if (target === 'done') return { ...v, doneDir: p }
|
||||||
if (target === 'postgresData') return { ...v, postgresMode: 'local', postgresDataDir: p }
|
if (target === 'postgresData') return { ...v, postgresMode: 'local', postgresDataDir: normalizePostgresDataDir(p) }
|
||||||
return { ...v, ffmpegPath: p }
|
return { ...v, ffmpegPath: p }
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -1504,6 +1521,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setLoadedPostgresUser(postgresUser)
|
setLoadedPostgresUser(postgresUser)
|
||||||
setLoadedDatabaseUrl(databaseUrl)
|
setLoadedDatabaseUrl(databaseUrl)
|
||||||
setPendingDbPassword('')
|
setPendingDbPassword('')
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
databaseType: 'postgres',
|
||||||
|
postgresMode,
|
||||||
|
postgresDataDir,
|
||||||
|
postgresPort,
|
||||||
|
postgresDatabase,
|
||||||
|
postgresUser,
|
||||||
|
databaseUrl,
|
||||||
|
}))
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||||
window.dispatchEvent(new Event('models-changed'))
|
window.dispatchEvent(new Event('models-changed'))
|
||||||
@ -1661,6 +1688,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
saveSuccessTimerRef.current = null
|
saveSuccessTimerRef.current = null
|
||||||
}, 2500)
|
}, 2500)
|
||||||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||||
|
setValue((v) => ({
|
||||||
|
...v,
|
||||||
|
databaseType,
|
||||||
|
postgresMode,
|
||||||
|
postgresDataDir,
|
||||||
|
postgresPort,
|
||||||
|
postgresDatabase,
|
||||||
|
postgresUser,
|
||||||
|
databaseUrl,
|
||||||
|
}))
|
||||||
|
|
||||||
const databaseChanged =
|
const databaseChanged =
|
||||||
postgresSourceKey({
|
postgresSourceKey({
|
||||||
@ -2433,9 +2470,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
<div className="sm:col-span-9 flex gap-2">
|
<div className="sm:col-span-9 flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={String((value as any).postgresDataDir ?? DEFAULTS.postgresDataDir ?? 'postgres-data')}
|
value={String((value as any).postgresDataDir ?? DEFAULTS.postgresDataDir ?? POSTGRES_DATA_DIR_NAME)}
|
||||||
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresDataDir: e.target.value }))}
|
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresDataDir: e.target.value }))}
|
||||||
placeholder="postgres-data"
|
placeholder={POSTGRES_DATA_DIR_NAME}
|
||||||
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||||||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user