updated
This commit is contained in:
parent
fe83e30725
commit
93e16e7f85
@ -127,9 +127,11 @@ configure_ml_python_setup() {
|
||||
|
||||
NSFWAPP_ML_SETUP=1
|
||||
NSFWAPP_ML_REQUIREMENTS="$requirements_file"
|
||||
NSFWAPP_ML_VENV="${NSFWAPP_ML_VENV:-$app_dir/ml-venv}"
|
||||
NSFWAPP_ML_SETUP_LOG="${NSFWAPP_ML_SETUP_LOG:-$app_dir/ml-setup.log}"
|
||||
export NSFWAPP_ML_SETUP NSFWAPP_ML_REQUIREMENTS NSFWAPP_ML_VENV NSFWAPP_ML_SETUP_LOG
|
||||
ml_tmp_root="${TMPDIR:-/tmp}"
|
||||
ml_tmp_user="${USER:-$(id -un 2>/dev/null || echo user)}"
|
||||
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
|
||||
}
|
||||
|
||||
@ -288,8 +290,8 @@ User-Daten:
|
||||
Wichtige Laufzeitprogramme:
|
||||
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.
|
||||
Der Launcher erstellt beim ersten Start automatisch ${NSFWAPP_HOME:-$HOME/nsfwapp}/ml-venv
|
||||
und installiert die mitgelieferte requirements-ml.txt. Details stehen in ml-setup.log.
|
||||
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 recorder.log.
|
||||
Setze AI_SERVER_PYTHON, wenn du eine eigene Python-Umgebung verwenden willst.
|
||||
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"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LogFile string `json:"logFile,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
@ -31,11 +30,11 @@ type mlPythonSetupStatus struct {
|
||||
|
||||
type mlPythonSetupConfig struct {
|
||||
enabled bool
|
||||
appDir string
|
||||
requirementsPath string
|
||||
venvDir string
|
||||
venvPython string
|
||||
requirementsMark string
|
||||
logPath string
|
||||
}
|
||||
|
||||
var (
|
||||
@ -60,7 +59,6 @@ func initMLPythonSetupStatus() {
|
||||
Progress: 0,
|
||||
Phase: "pending",
|
||||
Message: "ML-Python-Umgebung wartet auf Start.",
|
||||
LogFile: filepath.Base(cfg.logPath),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return
|
||||
@ -106,24 +104,58 @@ func mlPythonSetupConfigFromEnv() mlPythonSetupConfig {
|
||||
|
||||
venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV"))
|
||||
if venvDir == "" {
|
||||
venvDir = filepath.Join(appDir, "ml-venv")
|
||||
}
|
||||
|
||||
logPath := strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP_LOG"))
|
||||
if logPath == "" {
|
||||
logPath = filepath.Join(appDir, "ml-setup.log")
|
||||
venvDir = defaultMLPythonVenvDir()
|
||||
}
|
||||
|
||||
return mlPythonSetupConfig{
|
||||
enabled: true,
|
||||
appDir: filepath.Clean(appDir),
|
||||
requirementsPath: filepath.Clean(requirementsPath),
|
||||
venvDir: filepath.Clean(venvDir),
|
||||
venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"),
|
||||
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 {
|
||||
mlSetupMu.RLock()
|
||||
defer mlSetupMu.RUnlock()
|
||||
@ -171,7 +203,6 @@ func ensureMLPythonSetup(ctx context.Context) error {
|
||||
s.Phase = "prepare"
|
||||
s.Message = "ML-Python-Umgebung wird vorbereitet."
|
||||
s.Error = ""
|
||||
s.LogFile = filepath.Base(cfg.logPath)
|
||||
s.StartedAt = startedAt
|
||||
s.FinishedAt = ""
|
||||
})
|
||||
@ -184,9 +215,6 @@ func ensureMLPythonSetup(ctx context.Context) error {
|
||||
if err := os.MkdirAll(cfg.venvDir, 0755); err != nil {
|
||||
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 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
|
||||
})
|
||||
|
||||
logFile, err := os.OpenFile(cfg.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
_, _ = logFile.WriteString("\n[" + time.Now().Format(time.RFC3339) + "] " + name + " " + strings.Join(args, " ") + "\n")
|
||||
appLogln("[ml-setup] start:", name, strings.Join(args, " "))
|
||||
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = filepath.Dir(cfg.logPath)
|
||||
cmd.Dir = cfg.appDir
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@ -263,7 +285,7 @@ func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
_, _ = logFile.WriteString(line + "\n")
|
||||
appLogln("[ml-setup]", line)
|
||||
select {
|
||||
case lines <- line:
|
||||
case <-ctx.Done():
|
||||
@ -297,10 +319,14 @@ func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase
|
||||
}
|
||||
lineCount++
|
||||
progress := startProgress + minInt(progressRange-1, lineCount)
|
||||
visibleMessage := line
|
||||
if phase == "requirements" {
|
||||
visibleMessage = message
|
||||
}
|
||||
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||
s.Progress = progress
|
||||
s.Phase = phase
|
||||
s.Message = line
|
||||
s.Message = visibleMessage
|
||||
})
|
||||
|
||||
case err := <-done:
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@ -17,7 +18,9 @@ const (
|
||||
postgresModeURL = "url"
|
||||
postgresModeLocal = "local"
|
||||
|
||||
defaultPostgresDataDir = "postgres-data"
|
||||
postgresDataDirName = "nsfwapp-postgres-data"
|
||||
legacyPostgresDataDir = "postgres-data"
|
||||
defaultPostgresDataDir = postgresDataDirName
|
||||
defaultPostgresPort = 55432
|
||||
defaultPostgresDatabase = "nsfwapp"
|
||||
defaultPostgresUser = "nsfwapp"
|
||||
@ -39,7 +42,20 @@ func normalizePostgresDataDir(raw string) string {
|
||||
if value == "" {
|
||||
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 {
|
||||
@ -106,7 +122,7 @@ func postgresSettingsKey(s RecorderSettings) string {
|
||||
|
||||
func buildLocalPostgresDSN(s RecorderSettings) (string, error) {
|
||||
normalizePostgresSettings(&s)
|
||||
dataDir, err := resolvePathRelativeToApp(s.PostgresDataDir)
|
||||
dataDir, err := localPostgresDataDirAbs(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -139,7 +155,7 @@ func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
dataDir, err := resolvePathRelativeToApp(s.PostgresDataDir)
|
||||
dataDir, err := localPostgresDataDirAbs(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -149,6 +165,11 @@ func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) {
|
||||
return dataDir, nil
|
||||
}
|
||||
|
||||
func localPostgresDataDirAbs(s RecorderSettings) (string, error) {
|
||||
normalizePostgresSettings(&s)
|
||||
return resolvePathRelativeToApp(s.PostgresDataDir)
|
||||
}
|
||||
|
||||
func ensureLocalPostgresDataDirPath(dataDir string) error {
|
||||
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||
if dataDir == "" {
|
||||
@ -167,6 +188,209 @@ func ensureLocalPostgresDataDirPath(dataDir string) error {
|
||||
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 {
|
||||
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||
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)
|
||||
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
|
||||
// und URL ohne Passwort zurückschreiben.
|
||||
@ -583,11 +577,24 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
||||
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 :=
|
||||
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
||||
postgresSettingsKey(next) != postgresSettingsKey(current) ||
|
||||
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
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
current := getSettings()
|
||||
normalizePostgresSettings(¤t)
|
||||
target := current
|
||||
target.DatabaseType = databaseTypePostgres
|
||||
target.PostgresMode = in.PostgresMode
|
||||
@ -40,6 +41,50 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
pwFromURL = strings.TrimSpace(pwFromURL)
|
||||
if pwFromURL == "****" {
|
||||
|
||||
@ -50,7 +50,6 @@ type MLSetupStatus = {
|
||||
phase?: string
|
||||
message?: string
|
||||
error?: string
|
||||
logFile?: string
|
||||
}
|
||||
|
||||
type SetupResp = {
|
||||
@ -79,12 +78,14 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
||||
const title = failed
|
||||
? 'ML-Python-Setup fehlgeschlagen'
|
||||
: '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
|
||||
? String(status.error || '').trim()
|
||||
: status.logFile
|
||||
? `Log: ${status.logFile}`
|
||||
: ''
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -94,7 +95,7 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
||||
>
|
||||
<div
|
||||
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
|
||||
? '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',
|
||||
@ -103,7 +104,9 @@ function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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 className="shrink-0 text-xs font-semibold tabular-nums">
|
||||
{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 = {
|
||||
databaseType: 'postgres',
|
||||
postgresMode: 'local',
|
||||
postgresDataDir: 'postgres-data',
|
||||
postgresDataDir: POSTGRES_DATA_DIR_NAME,
|
||||
postgresPort: 55432,
|
||||
postgresDatabase: 'nsfwapp',
|
||||
postgresUser: 'nsfwapp',
|
||||
@ -558,7 +561,21 @@ function normalizePostgresMode(value: unknown, databaseUrl?: unknown): 'url' | '
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -737,7 +754,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const [dbModalOpen, setDbModalOpen] = useState(false)
|
||||
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
|
||||
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 [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
|
||||
const [loadedPostgresUser, setLoadedPostgresUser] = useState('nsfwapp')
|
||||
@ -1318,7 +1335,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setValue((v) => {
|
||||
if (target === 'record') return { ...v, recordDir: 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 }
|
||||
})
|
||||
} catch (e: any) {
|
||||
@ -1504,6 +1521,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setLoadedPostgresUser(postgresUser)
|
||||
setLoadedDatabaseUrl(databaseUrl)
|
||||
setPendingDbPassword('')
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
databaseType: 'postgres',
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
}))
|
||||
|
||||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
@ -1661,6 +1688,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
saveSuccessTimerRef.current = null
|
||||
}, 2500)
|
||||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
}))
|
||||
|
||||
const databaseChanged =
|
||||
postgresSourceKey({
|
||||
@ -2433,9 +2470,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<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 }))}
|
||||
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
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user