This commit is contained in:
Linrador 2026-06-24 10:00:03 +02:00
parent fe83e30725
commit 93e16e7f85
10 changed files with 397 additions and 53 deletions

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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:

View File

@ -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(&current)
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 {

View File

@ -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

View File

@ -25,6 +25,7 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
} }
current := getSettings() current := getSettings()
normalizePostgresSettings(&current)
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 == "****" {

View File

@ -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)}%

View File

@ -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"