updated
This commit is contained in:
parent
aa96f97851
commit
c6c6bad6cf
@ -285,11 +285,11 @@ User-Daten:
|
||||
Die installierte Binary liegt unter /opt/nsfwapp/nsfwapp.
|
||||
Beim Start wird sie nach ${NSFWAPP_HOME:-$HOME/nsfwapp}/nsfwapp kopiert
|
||||
und von dort ausgefuehrt. Dadurch bleiben recorder.log, recorder_settings.json,
|
||||
recorder_settings.json, PostgreSQL-Datenordner, generated/, record/ und record/done im User-Bereich.
|
||||
generated/, record/ und record/done im User-Bereich.
|
||||
|
||||
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.
|
||||
PostgreSQL ist die Datenbank. Fuer Migration/Backup werden pg_dump/pg_restore benoetigt.
|
||||
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.
|
||||
@ -302,7 +302,7 @@ Section: $SECTION
|
||||
Priority: $PRIORITY
|
||||
Architecture: $ARCH
|
||||
Maintainer: $MAINTAINER
|
||||
Depends: ca-certificates, ffmpeg, python3, python3-venv, xdg-utils, libnotify-bin, procps, postgresql
|
||||
Depends: ca-certificates, ffmpeg, python3, python3-venv, xdg-utils, libnotify-bin, procps, postgresql-client
|
||||
Description: $DESCRIPTION_SHORT
|
||||
$DESCRIPTION_LONG
|
||||
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.
@ -1,554 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresModeURL = "url"
|
||||
postgresModeLocal = "local"
|
||||
|
||||
postgresDataDirName = "nsfwapp-postgres-data"
|
||||
legacyPostgresDataDir = "postgres-data"
|
||||
defaultPostgresDataDir = postgresDataDirName
|
||||
defaultPostgresPort = 55432
|
||||
defaultPostgresDatabase = "nsfwapp"
|
||||
defaultPostgresUser = "nsfwapp"
|
||||
)
|
||||
|
||||
func normalizePostgresMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case postgresModeURL:
|
||||
return postgresModeURL
|
||||
case postgresModeLocal:
|
||||
return postgresModeLocal
|
||||
default:
|
||||
return postgresModeLocal
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePostgresDataDir(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = defaultPostgresDataDir
|
||||
}
|
||||
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 {
|
||||
if port < 1 || port > 65535 {
|
||||
return defaultPostgresPort
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func normalizePostgresName(raw, fallback string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = fallback
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else if b.Len() > 0 {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "_")
|
||||
if out == "" {
|
||||
return fallback
|
||||
}
|
||||
if len(out) > 63 {
|
||||
out = out[:63]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePostgresSettings(s *RecorderSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
|
||||
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
|
||||
if strings.TrimSpace(s.PostgresMode) == "" && s.DatabaseURL != "" {
|
||||
s.PostgresMode = postgresModeURL
|
||||
} else {
|
||||
s.PostgresMode = normalizePostgresMode(s.PostgresMode)
|
||||
}
|
||||
s.PostgresDataDir = normalizePostgresDataDir(s.PostgresDataDir)
|
||||
s.PostgresPort = normalizePostgresPort(s.PostgresPort)
|
||||
s.PostgresDatabase = normalizePostgresName(s.PostgresDatabase, defaultPostgresDatabase)
|
||||
s.PostgresUser = normalizePostgresName(s.PostgresUser, defaultPostgresUser)
|
||||
}
|
||||
|
||||
func postgresSettingsKey(s RecorderSettings) string {
|
||||
normalizePostgresSettings(&s)
|
||||
if s.PostgresMode == postgresModeLocal {
|
||||
return strings.Join([]string{
|
||||
s.PostgresMode,
|
||||
filepath.ToSlash(s.PostgresDataDir),
|
||||
strconv.Itoa(s.PostgresPort),
|
||||
s.PostgresDatabase,
|
||||
s.PostgresUser,
|
||||
}, "\x00")
|
||||
}
|
||||
return strings.Join([]string{s.PostgresMode, strings.TrimSpace(s.DatabaseURL)}, "\x00")
|
||||
}
|
||||
|
||||
func buildLocalPostgresDSN(s RecorderSettings) (string, error) {
|
||||
normalizePostgresSettings(&s)
|
||||
dataDir, err := localPostgresDataDirAbs(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(dataDir) == "" {
|
||||
return "", appErrorf("postgresDataDir ist leer")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := ensureLocalPostgresCluster(ctx, dataDir, s.PostgresPort, s.PostgresUser, s.PostgresDatabase); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.User(s.PostgresUser),
|
||||
Host: "127.0.0.1:" + strconv.Itoa(s.PostgresPort),
|
||||
Path: "/" + s.PostgresDatabase,
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) {
|
||||
normalizePostgresSettings(&s)
|
||||
if s.PostgresMode != postgresModeLocal {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
dataDir, err := localPostgresDataDirAbs(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ensureLocalPostgresDataDirPath(dataDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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 == "" {
|
||||
return appErrorf("postgresDataDir ist leer")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return appErrorf("postgresDataDir ist kein Ordner: %s", dataDir)
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
versionFile := filepath.Join(dataDir, "PG_VERSION")
|
||||
if _, err := os.Stat(versionFile); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := initLocalPostgresCluster(ctx, dataDir, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := startLocalPostgresCluster(ctx, dataDir, port); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureLocalPostgresDatabase(ctx, port, user, database)
|
||||
}
|
||||
|
||||
func initLocalPostgresCluster(ctx context.Context, dataDir, user string) error {
|
||||
initdb, err := postgresToolPath("initdb")
|
||||
if err != nil {
|
||||
return appErrorf("initdb wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm initdb in PATH auf.")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := runLocalPostgresTool(ctx, initdb,
|
||||
"-D", dataDir,
|
||||
"-U", user,
|
||||
"--encoding=UTF8",
|
||||
"--auth-local=trust",
|
||||
"--auth-host=trust",
|
||||
)
|
||||
if err != nil {
|
||||
return appErrorf("PostgreSQL-Datenordner konnte nicht initialisiert werden: %s", out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startLocalPostgresCluster(ctx context.Context, dataDir string, port int) 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.")
|
||||
}
|
||||
|
||||
statusOut, statusErr := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "status")
|
||||
if statusErr == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(statusOut), "server is running") || strings.Contains(strings.ToLower(statusOut), "server running") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ensureLocalPostgresPortAvailable(port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logPath := filepath.Join(dataDir, "postgresql.log")
|
||||
opts := fmt.Sprintf("-p %d -h 127.0.0.1", port)
|
||||
out, err := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "-l", logPath, "-o", opts, "-w", "start")
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(out), "server is running") || strings.Contains(strings.ToLower(out), "server running") {
|
||||
return nil
|
||||
}
|
||||
logTail := tailTextFile(logPath, 6000)
|
||||
if logTail != "" {
|
||||
appLogln("postgresql.log:", logTail)
|
||||
return appErrorf("lokaler PostgreSQL-Server konnte nicht gestartet werden: %s\n\npostgresql.log:\n%s", out, logTail)
|
||||
}
|
||||
return appErrorf("lokaler PostgreSQL-Server konnte nicht gestartet werden: %s", out)
|
||||
}
|
||||
|
||||
func ensureLocalPostgresPortAvailable(port int) error {
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
return appErrorf("Port %d ist bereits belegt. Bitte in den Einstellungen einen anderen PostgreSQL-Port wählen.", port)
|
||||
}
|
||||
|
||||
func tailTextFile(path string, maxBytes int) string {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 4000
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(b) > maxBytes {
|
||||
b = b[len(b)-maxBytes:]
|
||||
if idx := bytes.IndexByte(b, '\n'); idx >= 0 && idx+1 < len(b) {
|
||||
b = b[idx+1:]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func ensureLocalPostgresDatabase(ctx context.Context, port int, user, database string) error {
|
||||
psql, err := postgresToolPath("psql")
|
||||
if err != nil {
|
||||
return appErrorf("psql wurde nicht gefunden. Installiere postgresql-client oder nimm psql in PATH auf.")
|
||||
}
|
||||
|
||||
sql := "SELECT 1 FROM pg_database WHERE datname = '" + strings.ReplaceAll(database, "'", "''") + "';"
|
||||
out, err := runLocalPostgresTool(ctx, psql,
|
||||
"-h", "127.0.0.1",
|
||||
"-p", strconv.Itoa(port),
|
||||
"-U", user,
|
||||
"-d", "postgres",
|
||||
"-tAc", sql,
|
||||
)
|
||||
if err == nil && strings.TrimSpace(out) == "1" {
|
||||
return nil
|
||||
}
|
||||
|
||||
createdb, err := postgresToolPath("createdb")
|
||||
if err != nil {
|
||||
return appErrorf("createdb wurde nicht gefunden. Installiere postgresql-client oder nimm createdb in PATH auf.")
|
||||
}
|
||||
out, err = runLocalPostgresTool(ctx, createdb,
|
||||
"-h", "127.0.0.1",
|
||||
"-p", strconv.Itoa(port),
|
||||
"-U", user,
|
||||
database,
|
||||
)
|
||||
if err != nil {
|
||||
return appErrorf("lokale PostgreSQL-Datenbank konnte nicht angelegt werden: %s", out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLocalPostgresTool(ctx context.Context, tool string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, tool, args...)
|
||||
cmd.Env = upsertEnv(os.Environ(), "PGCONNECT_TIMEOUT", "10")
|
||||
hideCommandWindow(cmd)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
errOut := strings.TrimSpace(stderr.String())
|
||||
if errOut != "" {
|
||||
if out != "" {
|
||||
out += "\n"
|
||||
}
|
||||
out += errOut
|
||||
}
|
||||
return trimCommandOutput(out), err
|
||||
}
|
||||
16
backend/postgres_settings.go
Normal file
16
backend/postgres_settings.go
Normal file
@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
func normalizePostgresSettings(s *RecorderSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
|
||||
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
|
||||
}
|
||||
|
||||
func postgresSettingsKey(s RecorderSettings) string {
|
||||
normalizePostgresSettings(&s)
|
||||
return strings.TrimSpace(s.DatabaseURL)
|
||||
}
|
||||
@ -57,7 +57,6 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
||||
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
|
||||
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
|
||||
api.HandleFunc("/api/settings/database/migrate", settingsDatabaseMigrateHandler)
|
||||
|
||||
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||
|
||||
@ -172,9 +171,6 @@ func buildPostgresDSNFromSettings() (string, error) {
|
||||
// Settings sind bereits durch loadSettings() in main() geladen.
|
||||
s := getSettings()
|
||||
normalizePostgresSettings(&s)
|
||||
if s.PostgresMode == postgresModeLocal {
|
||||
return buildLocalPostgresDSN(s)
|
||||
}
|
||||
|
||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||
if dbURL == "" {
|
||||
@ -230,9 +226,6 @@ func buildPostgresDSNFromSettings() (string, error) {
|
||||
|
||||
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
|
||||
normalizePostgresSettings(&s)
|
||||
if s.PostgresMode == postgresModeLocal {
|
||||
return buildLocalPostgresDSN(s)
|
||||
}
|
||||
|
||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||
if dbURL == "" {
|
||||
|
||||
@ -15,11 +15,6 @@ import (
|
||||
|
||||
type RecorderSettings struct {
|
||||
DatabaseType string `json:"databaseType"`
|
||||
PostgresMode string `json:"postgresMode"` // url | local
|
||||
PostgresDataDir string `json:"postgresDataDir"`
|
||||
PostgresPort int `json:"postgresPort"`
|
||||
PostgresDatabase string `json:"postgresDatabase"`
|
||||
PostgresUser string `json:"postgresUser"`
|
||||
DatabaseURL string `json:"databaseUrl"`
|
||||
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
|
||||
|
||||
@ -72,11 +67,6 @@ var (
|
||||
settingsMu sync.Mutex
|
||||
settings = RecorderSettings{
|
||||
DatabaseType: databaseTypePostgres,
|
||||
PostgresMode: postgresModeLocal,
|
||||
PostgresDataDir: defaultPostgresDataDir,
|
||||
PostgresPort: defaultPostgresPort,
|
||||
PostgresDatabase: defaultPostgresDatabase,
|
||||
PostgresUser: defaultPostgresUser,
|
||||
DatabaseURL: "",
|
||||
EncryptedDBPassword: "",
|
||||
|
||||
@ -291,14 +281,9 @@ type RecorderSettingsPublic struct {
|
||||
DoneDir string `json:"doneDir"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
|
||||
DatabaseType string `json:"databaseType"`
|
||||
PostgresMode string `json:"postgresMode"`
|
||||
PostgresDataDir string `json:"postgresDataDir"`
|
||||
PostgresPort int `json:"postgresPort"`
|
||||
PostgresDatabase string `json:"postgresDatabase"`
|
||||
PostgresUser string `json:"postgresUser"`
|
||||
DatabaseURL string `json:"databaseUrl"`
|
||||
HasDBPassword bool `json:"hasDbPassword"`
|
||||
DatabaseType string `json:"databaseType"`
|
||||
DatabaseURL string `json:"databaseUrl"`
|
||||
HasDBPassword bool `json:"hasDbPassword"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||
@ -342,14 +327,9 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
DoneDir: s.DoneDir,
|
||||
FFmpegPath: s.FFmpegPath,
|
||||
|
||||
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
||||
PostgresMode: s.PostgresMode,
|
||||
PostgresDataDir: s.PostgresDataDir,
|
||||
PostgresPort: s.PostgresPort,
|
||||
PostgresDatabase: s.PostgresDatabase,
|
||||
PostgresUser: s.PostgresUser,
|
||||
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
||||
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||
|
||||
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
||||
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
|
||||
@ -491,7 +471,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
in.DatabaseType = normalizeDatabaseType(in.DatabaseType)
|
||||
normalizePostgresSettings(&in.RecorderSettings)
|
||||
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
||||
if in.PostgresMode == postgresModeURL && in.DatabaseURL == "" {
|
||||
if in.DatabaseURL == "" {
|
||||
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@ -535,11 +515,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
next.FFmpegPath = in.FFmpegPath
|
||||
|
||||
next.DatabaseType = in.DatabaseType
|
||||
next.PostgresMode = in.PostgresMode
|
||||
next.PostgresDataDir = in.PostgresDataDir
|
||||
next.PostgresPort = in.PostgresPort
|
||||
next.PostgresDatabase = in.PostgresDatabase
|
||||
next.PostgresUser = in.PostgresUser
|
||||
next.DatabaseURL = in.DatabaseURL
|
||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||
|
||||
@ -577,24 +552,11 @@ 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) ||
|
||||
postgresDataMoved
|
||||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
|
||||
|
||||
var newStore *ModelStore
|
||||
|
||||
@ -663,8 +625,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.Query().Get("target")
|
||||
if target != "record" && target != "done" && target != "ffmpeg" && target != "postgresData" {
|
||||
http.Error(w, "target muss record, done, ffmpeg oder postgresData sein", http.StatusBadRequest)
|
||||
if target != "record" && target != "done" && target != "ffmpeg" {
|
||||
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@ -678,10 +640,6 @@ func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
p, err = dialog.File().
|
||||
Title("ffmpeg.exe auswählen").
|
||||
Load()
|
||||
} else if target == "postgresData" {
|
||||
p, err = dialog.Directory().
|
||||
Title("PostgreSQL-Datenordner auswaehlen").
|
||||
Browse()
|
||||
} else {
|
||||
// Ordnerauswahl für record/done
|
||||
p, err = dialog.Directory().
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var in RecorderSettingsIn
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
current := getSettings()
|
||||
normalizePostgresSettings(¤t)
|
||||
target := current
|
||||
target.DatabaseType = databaseTypePostgres
|
||||
target.PostgresMode = in.PostgresMode
|
||||
target.PostgresDataDir = in.PostgresDataDir
|
||||
target.PostgresPort = in.PostgresPort
|
||||
target.PostgresDatabase = in.PostgresDatabase
|
||||
target.PostgresUser = in.PostgresUser
|
||||
target.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
||||
normalizePostgresSettings(&target)
|
||||
|
||||
if target.PostgresMode == postgresModeURL && target.DatabaseURL == "" {
|
||||
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
||||
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 == "****" {
|
||||
pwFromURL = ""
|
||||
}
|
||||
if sanitizedURL != "" {
|
||||
target.DatabaseURL = sanitizedURL
|
||||
}
|
||||
|
||||
plainPW := strings.TrimSpace(in.DBPassword)
|
||||
if plainPW == "" {
|
||||
plainPW = pwFromURL
|
||||
}
|
||||
if plainPW != "" {
|
||||
enc, err := encryptSettingString(plainPW)
|
||||
if err != nil {
|
||||
http.Error(w, "konnte DB-Passwort nicht verschluesseln: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
target.EncryptedDBPassword = enc
|
||||
}
|
||||
|
||||
sourceDSN, err := buildPostgresDSNFromRecorderSettings(current)
|
||||
if err != nil {
|
||||
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targetDSN, err := buildPostgresDSNFromRecorderSettings(target)
|
||||
if err != nil {
|
||||
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(sourceDSN) == strings.TrimSpace(targetDSN) ||
|
||||
sanitizeDSNForLog(sourceDSN) == sanitizeDSNForLog(targetDSN) {
|
||||
http.Error(w, "Quelle und Ziel sind identisch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sourceDBURL, sourceEnv, err := postgresToolConnection(sourceDSN)
|
||||
if err != nil {
|
||||
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
targetDBURL, targetEnv, err := postgresToolConnection(targetDSN)
|
||||
if err != nil {
|
||||
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "nsfwapp-db-migrate-*.dump")
|
||||
if err != nil {
|
||||
http.Error(w, "Migration konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := dumpPostgresDatabase(ctx, sourceDBURL, sourceEnv, tmpPath); err != nil {
|
||||
http.Error(w, "pg_dump fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := restorePostgresDatabase(ctx, targetDBURL, targetEnv, tmpPath); err != nil {
|
||||
http.Error(w, "pg_restore fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: targetDSN})
|
||||
if err := newStore.Load(); err != nil {
|
||||
_ = newStore.Close()
|
||||
http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
settingsMu.Lock()
|
||||
settings = target
|
||||
settingsMu.Unlock()
|
||||
saveSettingsToDisk()
|
||||
|
||||
setModelStore(newStore)
|
||||
setCoverModelStore(newStore)
|
||||
setChaturbateOnlineModelStore(newStore)
|
||||
|
||||
info, _ := os.Stat(tmpPath)
|
||||
var bytesWritten int64
|
||||
if info != nil {
|
||||
bytesWritten = info.Size()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"bytes": bytesWritten,
|
||||
"mode": target.PostgresMode,
|
||||
})
|
||||
}
|
||||
|
||||
func dumpPostgresDatabase(ctx context.Context, dbURL string, env []string, outPath string) error {
|
||||
toolPath, err := postgresToolPath("pg_dump")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
toolPath,
|
||||
"--format=custom",
|
||||
"--blobs",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--dbname="+dbURL,
|
||||
)
|
||||
cmd.Env = env
|
||||
cmd.Stdout = out
|
||||
hideCommandWindow(cmd)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
closeErr := out.Close()
|
||||
if runErr != nil {
|
||||
msg := trimCommandOutput(stderr.String())
|
||||
if msg == "" {
|
||||
msg = runErr.Error()
|
||||
}
|
||||
return appErrorf("%s", msg)
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func restorePostgresDatabase(ctx context.Context, dbURL string, env []string, dumpPath string) error {
|
||||
stderr, err := runPostgresTool(
|
||||
ctx,
|
||||
"pg_restore",
|
||||
postgresToolConfig{dbURL: dbURL, env: env},
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--single-transaction",
|
||||
"--dbname="+dbURL,
|
||||
filepath.Clean(dumpPath),
|
||||
)
|
||||
if err != nil {
|
||||
return appErrorf("%s", stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -14,11 +14,6 @@ import { startRegistration } from '@simplewebauthn/browser'
|
||||
|
||||
type RecorderSettings = {
|
||||
databaseType?: 'postgres'
|
||||
postgresMode?: 'url' | 'local'
|
||||
postgresDataDir?: string
|
||||
postgresPort?: number
|
||||
postgresDatabase?: string
|
||||
postgresUser?: string
|
||||
databaseUrl?: string
|
||||
hasDbPassword?: boolean
|
||||
recordDir: string
|
||||
@ -284,16 +279,8 @@ 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_DIR_NAME,
|
||||
postgresPort: 55432,
|
||||
postgresDatabase: 'nsfwapp',
|
||||
postgresUser: 'nsfwapp',
|
||||
databaseUrl: '',
|
||||
hasDbPassword: false,
|
||||
recordDir: 'record',
|
||||
@ -553,53 +540,8 @@ function normalizeDatabaseType(_value: unknown): 'postgres' {
|
||||
return 'postgres'
|
||||
}
|
||||
|
||||
function normalizePostgresMode(value: unknown, databaseUrl?: unknown): 'url' | 'local' {
|
||||
const mode = String(value ?? '').trim().toLowerCase()
|
||||
if (mode === 'url') return 'url'
|
||||
if (mode === 'local') return 'local'
|
||||
return String(databaseUrl ?? '').trim() ? 'url' : 'local'
|
||||
}
|
||||
|
||||
function normalizePostgresDataDir(value: unknown) {
|
||||
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) {
|
||||
const n = Math.floor(Number(value))
|
||||
return Number.isFinite(n) && n >= 1 && n <= 65535 ? n : 55432
|
||||
}
|
||||
|
||||
function normalizePostgresName(value: unknown, fallback: string) {
|
||||
const s = String(value ?? '').trim()
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
function postgresSourceKey(v: any) {
|
||||
const mode = normalizePostgresMode(v?.postgresMode, v?.databaseUrl)
|
||||
if (mode === 'local') {
|
||||
return [
|
||||
mode,
|
||||
normalizePostgresDataDir(v?.postgresDataDir),
|
||||
normalizePostgresPort(v?.postgresPort),
|
||||
normalizePostgresName(v?.postgresDatabase, 'nsfwapp'),
|
||||
normalizePostgresName(v?.postgresUser, 'nsfwapp'),
|
||||
].join('\u0000')
|
||||
}
|
||||
return [mode, String(v?.databaseUrl ?? '').trim()].join('\u0000')
|
||||
return String(v?.databaseUrl ?? '').trim()
|
||||
}
|
||||
|
||||
function formatDatabaseBackupBytes(bytes: unknown) {
|
||||
@ -743,7 +685,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
||||
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | 'postgresData' | null>(null)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
||||
@ -753,45 +695,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const [appLogOpen, setAppLogOpen] = useState(false)
|
||||
const [dbModalOpen, setDbModalOpen] = useState(false)
|
||||
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
|
||||
const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('local')
|
||||
const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState(POSTGRES_DATA_DIR_NAME)
|
||||
const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432)
|
||||
const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
|
||||
const [loadedPostgresUser, setLoadedPostgresUser] = useState('nsfwapp')
|
||||
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
||||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||
const [dbMigrationBusy, setDbMigrationBusy] = useState(false)
|
||||
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
||||
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
||||
const now = Date.now()
|
||||
const saveSucceeded = saveSuccessUntilMs > now
|
||||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
||||
const currentPostgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
|
||||
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||||
const loadedPostgresSourceKey = postgresSourceKey({
|
||||
databaseType: loadedDatabaseType,
|
||||
databaseUrl: loadedDatabaseUrl,
|
||||
postgresMode: loadedPostgresMode,
|
||||
postgresDataDir: loadedPostgresDataDir,
|
||||
postgresPort: loadedPostgresPort,
|
||||
postgresDatabase: loadedPostgresDatabase,
|
||||
postgresUser: loadedPostgresUser,
|
||||
})
|
||||
const currentPostgresSourceKey = postgresSourceKey(value)
|
||||
const databaseLocationDirty = currentPostgresSourceKey !== loadedPostgresSourceKey
|
||||
const databaseSettingsDirty =
|
||||
databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
|
||||
const savedDatabaseConfigured =
|
||||
loadedPostgresMode === 'local' ? Boolean(loadedPostgresDataDir) : Boolean(loadedDatabaseUrl)
|
||||
const targetDatabaseConfigured =
|
||||
currentPostgresMode === 'local'
|
||||
? Boolean(normalizePostgresDataDir((value as any).postgresDataDir))
|
||||
: Boolean(currentDatabaseUrl)
|
||||
const savedDatabaseConfigured = Boolean(loadedDatabaseUrl)
|
||||
const databaseConfigured = savedDatabaseConfigured
|
||||
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
|
||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
|
||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
|
||||
|
||||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||
const [securityBusy, setSecurityBusy] = useState(false)
|
||||
@ -930,18 +853,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
.then((data: RecorderSettings) => {
|
||||
if (!alive) return
|
||||
const databaseType = normalizeDatabaseType((data as any).databaseType)
|
||||
const postgresMode = normalizePostgresMode((data as any).postgresMode, (data as any).databaseUrl)
|
||||
const postgresDataDir = normalizePostgresDataDir((data as any).postgresDataDir)
|
||||
const postgresPort = normalizePostgresPort((data as any).postgresPort)
|
||||
const postgresDatabase = normalizePostgresName((data as any).postgresDatabase, 'nsfwapp')
|
||||
const postgresUser = normalizePostgresName((data as any).postgresUser, 'nsfwapp')
|
||||
setValue({
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl: String((data as any).databaseUrl ?? ''),
|
||||
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
|
||||
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
||||
@ -993,11 +906,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
|
||||
})
|
||||
setLoadedDatabaseType(databaseType)
|
||||
setLoadedPostgresMode(postgresMode)
|
||||
setLoadedPostgresDataDir(postgresDataDir)
|
||||
setLoadedPostgresPort(postgresPort)
|
||||
setLoadedPostgresDatabase(postgresDatabase)
|
||||
setLoadedPostgresUser(postgresUser)
|
||||
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
||||
})
|
||||
.catch(() => {
|
||||
@ -1314,7 +1222,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
scrollAppLogToBottom()
|
||||
}, [appLogOpen, appLog?.log, appLogLoading])
|
||||
|
||||
async function browse(target: 'record' | 'done' | 'ffmpeg' | 'postgresData') {
|
||||
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
setBrowsing(target)
|
||||
@ -1335,7 +1243,6 @@ 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: normalizePostgresDataDir(p) }
|
||||
return { ...v, ffmpegPath: p }
|
||||
})
|
||||
} catch (e: any) {
|
||||
@ -1436,11 +1343,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
new CustomEvent('models-db-changed', {
|
||||
detail: {
|
||||
databaseType: loadedDatabaseType,
|
||||
postgresMode: loadedPostgresMode,
|
||||
postgresDataDir: loadedPostgresDataDir,
|
||||
postgresPort: loadedPostgresPort,
|
||||
postgresDatabase: loadedPostgresDatabase,
|
||||
postgresUser: loadedPostgresUser,
|
||||
databaseUrl: loadedDatabaseUrl,
|
||||
restored: true,
|
||||
},
|
||||
@ -1453,108 +1355,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateDatabase() {
|
||||
setDbMaintenanceMsg(null)
|
||||
setDbMaintenanceErr(null)
|
||||
|
||||
const postgresMode = currentPostgresMode
|
||||
const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir)
|
||||
const postgresPort = normalizePostgresPort((value as any).postgresPort)
|
||||
const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp')
|
||||
const postgresUser = normalizePostgresName((value as any).postgresUser, 'nsfwapp')
|
||||
const databaseUrl = currentDatabaseUrl
|
||||
|
||||
if (!savedDatabaseConfigured) {
|
||||
setDbMaintenanceErr('Bitte zuerst eine Quell-Datenbank speichern.')
|
||||
return
|
||||
}
|
||||
if (!targetDatabaseConfigured) {
|
||||
setDbMaintenanceErr('Bitte zuerst die Ziel-Datenbank angeben.')
|
||||
return
|
||||
}
|
||||
if (!databaseLocationDirty) {
|
||||
setDbMaintenanceErr('Quelle und Ziel sind identisch.')
|
||||
return
|
||||
}
|
||||
|
||||
const targetLabel =
|
||||
postgresMode === 'local'
|
||||
? `lokalen Datenordner "${postgresDataDir}"`
|
||||
: `Datenbank "${databaseUrl}"`
|
||||
const ok = window.confirm(
|
||||
`Aktive PostgreSQL-Datenbank nach ${targetLabel} migrieren?\n\n` +
|
||||
'Die Ziel-Datenbank wird mit den aktuellen Model-Daten überschrieben. Danach nutzt die App das Ziel.'
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
setDbMigrationBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings/database/migrate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
databaseType: 'postgres',
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
dbPassword: pendingDbPassword || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '')
|
||||
throw new Error(t || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null)
|
||||
const size = formatDatabaseBackupBytes(data?.bytes ?? 0)
|
||||
setDbMaintenanceMsg(size ? `Migration abgeschlossen (${size}).` : 'Migration abgeschlossen.')
|
||||
|
||||
setLoadedDatabaseType('postgres')
|
||||
setLoadedPostgresMode(postgresMode)
|
||||
setLoadedPostgresDataDir(postgresDataDir)
|
||||
setLoadedPostgresPort(postgresPort)
|
||||
setLoadedPostgresDatabase(postgresDatabase)
|
||||
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'))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('models-db-changed', {
|
||||
detail: {
|
||||
databaseType: 'postgres',
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
migrated: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
} catch (e: any) {
|
||||
setDbMaintenanceErr(e?.message ?? String(e))
|
||||
} finally {
|
||||
setDbMigrationBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
@ -1563,11 +1363,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const doneDir = value.doneDir.trim()
|
||||
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
||||
const databaseType = normalizeDatabaseType((value as any).databaseType)
|
||||
const postgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
|
||||
const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir)
|
||||
const postgresPort = normalizePostgresPort((value as any).postgresPort)
|
||||
const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp')
|
||||
const postgresUser = normalizePostgresName((value as any).postgresUser, 'nsfwapp')
|
||||
const databaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||||
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
|
||||
|
||||
@ -1575,14 +1370,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
||||
return
|
||||
}
|
||||
if (postgresMode === 'url' && !databaseUrl) {
|
||||
if (!databaseUrl) {
|
||||
setErr('Bitte Database URL angeben.')
|
||||
return
|
||||
}
|
||||
if (postgresMode === 'local' && !postgresDataDir) {
|
||||
setErr('Bitte PostgreSQL-Datenordner angeben.')
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||||
@ -1635,11 +1426,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
dbPassword: pendingDbPassword || undefined,
|
||||
recordDir,
|
||||
@ -1691,22 +1477,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
}))
|
||||
|
||||
const databaseChanged =
|
||||
postgresSourceKey({
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
}) !== loadedPostgresSourceKey ||
|
||||
hadPendingDbPassword
|
||||
@ -1716,21 +1492,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
new CustomEvent('models-db-changed', {
|
||||
detail: {
|
||||
databaseType,
|
||||
postgresMode,
|
||||
postgresDataDir,
|
||||
postgresPort,
|
||||
postgresDatabase,
|
||||
postgresUser,
|
||||
databaseUrl,
|
||||
},
|
||||
})
|
||||
)
|
||||
setLoadedDatabaseType(databaseType)
|
||||
setLoadedPostgresMode(postgresMode)
|
||||
setLoadedPostgresDataDir(postgresDataDir)
|
||||
setLoadedPostgresPort(postgresPort)
|
||||
setLoadedPostgresDatabase(postgresDatabase)
|
||||
setLoadedPostgresUser(postgresUser)
|
||||
setLoadedDatabaseUrl(databaseUrl)
|
||||
setPendingDbPassword('')
|
||||
}
|
||||
@ -2403,44 +2169,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
PostgreSQL als externe Datenbank oder lokal verwalteter Datenordner.
|
||||
PostgreSQL-Verbindung über eine Database URL.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Quelle
|
||||
</label>
|
||||
|
||||
<div className="sm:col-span-9">
|
||||
<div className="inline-flex rounded-lg bg-gray-100 p-1 ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
|
||||
{([
|
||||
['url', 'Datenbank'],
|
||||
['local', 'Datenordner'],
|
||||
] as const).map(([mode, label]) => {
|
||||
const active = currentPostgresMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setValue((v) => ({ ...v, postgresMode: mode, databaseType: 'postgres' }))}
|
||||
className={[
|
||||
'rounded-md px-3 py-1.5 text-sm font-semibold transition',
|
||||
active
|
||||
? 'bg-white text-gray-950 shadow-sm dark:bg-gray-950 dark:text-white'
|
||||
: 'text-gray-600 hover:text-gray-950 dark:text-gray-300 dark:hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPostgresMode === 'url' ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Database URL
|
||||
@ -2461,65 +2194,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Datenordner
|
||||
</label>
|
||||
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
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_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"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => browse('postgresData')} disabled={saving || browsing !== null}>
|
||||
Speicherort...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Verbindung
|
||||
</label>
|
||||
|
||||
<div className="sm:col-span-9 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={Number((value as any).postgresPort ?? DEFAULTS.postgresPort ?? 55432)}
|
||||
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresPort: Number(e.target.value || 55432) }))}
|
||||
placeholder="Port"
|
||||
className="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"
|
||||
/>
|
||||
<input
|
||||
value={String((value as any).postgresDatabase ?? DEFAULTS.postgresDatabase ?? 'nsfwapp')}
|
||||
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresDatabase: e.target.value }))}
|
||||
placeholder="Datenbank"
|
||||
className="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"
|
||||
/>
|
||||
<input
|
||||
value={String((value as any).postgresUser ?? DEFAULTS.postgresUser ?? 'nsfwapp')}
|
||||
onChange={(e) => setValue((v) => ({ ...v, postgresMode: 'local', postgresUser: e.target.value }))}
|
||||
placeholder="User"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 pt-3 dark:border-white/10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@ -2538,16 +2212,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
color="blue"
|
||||
isLoading={dbMigrationBusy}
|
||||
disabled={saving || dbMaintenanceBusy || !canMigrateDatabase}
|
||||
onClick={() => void migrateDatabase()}
|
||||
>
|
||||
Migrieren und wechseln
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
color="emerald"
|
||||
@ -2600,7 +2264,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
initialUrl={String((value as any).databaseUrl ?? '')}
|
||||
initialHasPassword={Boolean((value as any).hasDbPassword)}
|
||||
onApply={({ databaseUrl, dbPassword }) => {
|
||||
setValue((v) => ({ ...v, postgresMode: 'url', databaseUrl }))
|
||||
setValue((v) => ({ ...v, databaseUrl }))
|
||||
setPendingDbPassword(dbPassword || '')
|
||||
}}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user