updated
This commit is contained in:
parent
aa96f97851
commit
c6c6bad6cf
@ -285,11 +285,11 @@ User-Daten:
|
|||||||
Die installierte Binary liegt unter /opt/nsfwapp/nsfwapp.
|
Die installierte Binary liegt unter /opt/nsfwapp/nsfwapp.
|
||||||
Beim Start wird sie nach ${NSFWAPP_HOME:-$HOME/nsfwapp}/nsfwapp kopiert
|
Beim Start wird sie nach ${NSFWAPP_HOME:-$HOME/nsfwapp}/nsfwapp kopiert
|
||||||
und von dort ausgefuehrt. Dadurch bleiben recorder.log, recorder_settings.json,
|
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:
|
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 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>
|
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.
|
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.
|
||||||
@ -302,7 +302,7 @@ Section: $SECTION
|
|||||||
Priority: $PRIORITY
|
Priority: $PRIORITY
|
||||||
Architecture: $ARCH
|
Architecture: $ARCH
|
||||||
Maintainer: $MAINTAINER
|
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: $DESCRIPTION_SHORT
|
||||||
$DESCRIPTION_LONG
|
$DESCRIPTION_LONG
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -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/cleanup", settingsCleanupHandler)
|
||||||
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
|
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
|
||||||
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
|
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
|
||||||
api.HandleFunc("/api/settings/database/migrate", settingsDatabaseMigrateHandler)
|
|
||||||
|
|
||||||
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||||
|
|
||||||
@ -172,9 +171,6 @@ func buildPostgresDSNFromSettings() (string, error) {
|
|||||||
// Settings sind bereits durch loadSettings() in main() geladen.
|
// Settings sind bereits durch loadSettings() in main() geladen.
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
normalizePostgresSettings(&s)
|
normalizePostgresSettings(&s)
|
||||||
if s.PostgresMode == postgresModeLocal {
|
|
||||||
return buildLocalPostgresDSN(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
@ -230,9 +226,6 @@ func buildPostgresDSNFromSettings() (string, error) {
|
|||||||
|
|
||||||
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
|
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
|
||||||
normalizePostgresSettings(&s)
|
normalizePostgresSettings(&s)
|
||||||
if s.PostgresMode == postgresModeLocal {
|
|
||||||
return buildLocalPostgresDSN(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
|
|||||||
@ -15,11 +15,6 @@ import (
|
|||||||
|
|
||||||
type RecorderSettings struct {
|
type RecorderSettings struct {
|
||||||
DatabaseType string `json:"databaseType"`
|
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"`
|
DatabaseURL string `json:"databaseUrl"`
|
||||||
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
|
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
|
||||||
|
|
||||||
@ -72,11 +67,6 @@ var (
|
|||||||
settingsMu sync.Mutex
|
settingsMu sync.Mutex
|
||||||
settings = RecorderSettings{
|
settings = RecorderSettings{
|
||||||
DatabaseType: databaseTypePostgres,
|
DatabaseType: databaseTypePostgres,
|
||||||
PostgresMode: postgresModeLocal,
|
|
||||||
PostgresDataDir: defaultPostgresDataDir,
|
|
||||||
PostgresPort: defaultPostgresPort,
|
|
||||||
PostgresDatabase: defaultPostgresDatabase,
|
|
||||||
PostgresUser: defaultPostgresUser,
|
|
||||||
DatabaseURL: "",
|
DatabaseURL: "",
|
||||||
EncryptedDBPassword: "",
|
EncryptedDBPassword: "",
|
||||||
|
|
||||||
@ -292,11 +282,6 @@ type RecorderSettingsPublic struct {
|
|||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
|
|
||||||
DatabaseType string `json:"databaseType"`
|
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"`
|
DatabaseURL string `json:"databaseUrl"`
|
||||||
HasDBPassword bool `json:"hasDbPassword"`
|
HasDBPassword bool `json:"hasDbPassword"`
|
||||||
|
|
||||||
@ -343,11 +328,6 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
FFmpegPath: s.FFmpegPath,
|
FFmpegPath: s.FFmpegPath,
|
||||||
|
|
||||||
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
||||||
PostgresMode: s.PostgresMode,
|
|
||||||
PostgresDataDir: s.PostgresDataDir,
|
|
||||||
PostgresPort: s.PostgresPort,
|
|
||||||
PostgresDatabase: s.PostgresDatabase,
|
|
||||||
PostgresUser: s.PostgresUser,
|
|
||||||
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
||||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||||
|
|
||||||
@ -491,7 +471,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.DatabaseType = normalizeDatabaseType(in.DatabaseType)
|
in.DatabaseType = normalizeDatabaseType(in.DatabaseType)
|
||||||
normalizePostgresSettings(&in.RecorderSettings)
|
normalizePostgresSettings(&in.RecorderSettings)
|
||||||
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
||||||
if in.PostgresMode == postgresModeURL && in.DatabaseURL == "" {
|
if in.DatabaseURL == "" {
|
||||||
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -535,11 +515,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
next.FFmpegPath = in.FFmpegPath
|
next.FFmpegPath = in.FFmpegPath
|
||||||
|
|
||||||
next.DatabaseType = in.DatabaseType
|
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.DatabaseURL = in.DatabaseURL
|
||||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||||
|
|
||||||
@ -577,24 +552,11 @@ 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
|
||||||
|
|
||||||
@ -663,8 +625,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||||||
target := r.URL.Query().Get("target")
|
target := r.URL.Query().Get("target")
|
||||||
if target != "record" && target != "done" && target != "ffmpeg" && target != "postgresData" {
|
if target != "record" && target != "done" && target != "ffmpeg" {
|
||||||
http.Error(w, "target muss record, done, ffmpeg oder postgresData sein", http.StatusBadRequest)
|
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -678,10 +640,6 @@ func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
|||||||
p, err = dialog.File().
|
p, err = dialog.File().
|
||||||
Title("ffmpeg.exe auswählen").
|
Title("ffmpeg.exe auswählen").
|
||||||
Load()
|
Load()
|
||||||
} else if target == "postgresData" {
|
|
||||||
p, err = dialog.Directory().
|
|
||||||
Title("PostgreSQL-Datenordner auswaehlen").
|
|
||||||
Browse()
|
|
||||||
} else {
|
} else {
|
||||||
// Ordnerauswahl für record/done
|
// Ordnerauswahl für record/done
|
||||||
p, err = dialog.Directory().
|
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 = {
|
type RecorderSettings = {
|
||||||
databaseType?: 'postgres'
|
databaseType?: 'postgres'
|
||||||
postgresMode?: 'url' | 'local'
|
|
||||||
postgresDataDir?: string
|
|
||||||
postgresPort?: number
|
|
||||||
postgresDatabase?: string
|
|
||||||
postgresUser?: string
|
|
||||||
databaseUrl?: string
|
databaseUrl?: string
|
||||||
hasDbPassword?: boolean
|
hasDbPassword?: boolean
|
||||||
recordDir: string
|
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 = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
databaseType: 'postgres',
|
databaseType: 'postgres',
|
||||||
postgresMode: 'local',
|
|
||||||
postgresDataDir: POSTGRES_DATA_DIR_NAME,
|
|
||||||
postgresPort: 55432,
|
|
||||||
postgresDatabase: 'nsfwapp',
|
|
||||||
postgresUser: 'nsfwapp',
|
|
||||||
databaseUrl: '',
|
databaseUrl: '',
|
||||||
hasDbPassword: false,
|
hasDbPassword: false,
|
||||||
recordDir: 'record',
|
recordDir: 'record',
|
||||||
@ -553,53 +540,8 @@ function normalizeDatabaseType(_value: unknown): 'postgres' {
|
|||||||
return '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) {
|
function postgresSourceKey(v: any) {
|
||||||
const mode = normalizePostgresMode(v?.postgresMode, v?.databaseUrl)
|
return String(v?.databaseUrl ?? '').trim()
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDatabaseBackupBytes(bytes: unknown) {
|
function formatDatabaseBackupBytes(bytes: unknown) {
|
||||||
@ -743,7 +685,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||||
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
||||||
const dbRestoreInputRef = useRef<HTMLInputElement | 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 [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [diskStatus, setDiskStatus] = useState<DiskStatus | 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 [appLogOpen, setAppLogOpen] = useState(false)
|
||||||
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 [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 [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
||||||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbMigrationBusy, setDbMigrationBusy] = useState(false)
|
|
||||||
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
||||||
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const saveSucceeded = saveSuccessUntilMs > now
|
const saveSucceeded = saveSuccessUntilMs > now
|
||||||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
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({
|
const loadedPostgresSourceKey = postgresSourceKey({
|
||||||
databaseType: loadedDatabaseType,
|
databaseType: loadedDatabaseType,
|
||||||
databaseUrl: loadedDatabaseUrl,
|
databaseUrl: loadedDatabaseUrl,
|
||||||
postgresMode: loadedPostgresMode,
|
|
||||||
postgresDataDir: loadedPostgresDataDir,
|
|
||||||
postgresPort: loadedPostgresPort,
|
|
||||||
postgresDatabase: loadedPostgresDatabase,
|
|
||||||
postgresUser: loadedPostgresUser,
|
|
||||||
})
|
})
|
||||||
const currentPostgresSourceKey = postgresSourceKey(value)
|
const currentPostgresSourceKey = postgresSourceKey(value)
|
||||||
const databaseLocationDirty = currentPostgresSourceKey !== loadedPostgresSourceKey
|
const databaseLocationDirty = currentPostgresSourceKey !== loadedPostgresSourceKey
|
||||||
const databaseSettingsDirty =
|
const databaseSettingsDirty =
|
||||||
databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
|
databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
|
||||||
const savedDatabaseConfigured =
|
const savedDatabaseConfigured = Boolean(loadedDatabaseUrl)
|
||||||
loadedPostgresMode === 'local' ? Boolean(loadedPostgresDataDir) : Boolean(loadedDatabaseUrl)
|
|
||||||
const targetDatabaseConfigured =
|
|
||||||
currentPostgresMode === 'local'
|
|
||||||
? Boolean(normalizePostgresDataDir((value as any).postgresDataDir))
|
|
||||||
: Boolean(currentDatabaseUrl)
|
|
||||||
const databaseConfigured = savedDatabaseConfigured
|
const databaseConfigured = savedDatabaseConfigured
|
||||||
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
|
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
|
||||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
|
|
||||||
|
|
||||||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||||
const [securityBusy, setSecurityBusy] = useState(false)
|
const [securityBusy, setSecurityBusy] = useState(false)
|
||||||
@ -930,18 +853,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
.then((data: RecorderSettings) => {
|
.then((data: RecorderSettings) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
const databaseType = normalizeDatabaseType((data as any).databaseType)
|
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({
|
setValue({
|
||||||
databaseType,
|
databaseType,
|
||||||
postgresMode,
|
|
||||||
postgresDataDir,
|
|
||||||
postgresPort,
|
|
||||||
postgresDatabase,
|
|
||||||
postgresUser,
|
|
||||||
databaseUrl: String((data as any).databaseUrl ?? ''),
|
databaseUrl: String((data as any).databaseUrl ?? ''),
|
||||||
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
|
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
|
||||||
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
||||||
@ -993,11 +906,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
|
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
|
||||||
})
|
})
|
||||||
setLoadedDatabaseType(databaseType)
|
setLoadedDatabaseType(databaseType)
|
||||||
setLoadedPostgresMode(postgresMode)
|
|
||||||
setLoadedPostgresDataDir(postgresDataDir)
|
|
||||||
setLoadedPostgresPort(postgresPort)
|
|
||||||
setLoadedPostgresDatabase(postgresDatabase)
|
|
||||||
setLoadedPostgresUser(postgresUser)
|
|
||||||
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@ -1314,7 +1222,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
scrollAppLogToBottom()
|
scrollAppLogToBottom()
|
||||||
}, [appLogOpen, appLog?.log, appLogLoading])
|
}, [appLogOpen, appLog?.log, appLogLoading])
|
||||||
|
|
||||||
async function browse(target: 'record' | 'done' | 'ffmpeg' | 'postgresData') {
|
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
setBrowsing(target)
|
setBrowsing(target)
|
||||||
@ -1335,7 +1243,6 @@ 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: normalizePostgresDataDir(p) }
|
|
||||||
return { ...v, ffmpegPath: p }
|
return { ...v, ffmpegPath: p }
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -1436,11 +1343,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
new CustomEvent('models-db-changed', {
|
new CustomEvent('models-db-changed', {
|
||||||
detail: {
|
detail: {
|
||||||
databaseType: loadedDatabaseType,
|
databaseType: loadedDatabaseType,
|
||||||
postgresMode: loadedPostgresMode,
|
|
||||||
postgresDataDir: loadedPostgresDataDir,
|
|
||||||
postgresPort: loadedPostgresPort,
|
|
||||||
postgresDatabase: loadedPostgresDatabase,
|
|
||||||
postgresUser: loadedPostgresUser,
|
|
||||||
databaseUrl: loadedDatabaseUrl,
|
databaseUrl: loadedDatabaseUrl,
|
||||||
restored: true,
|
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() {
|
async function save() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
@ -1563,11 +1363,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const doneDir = value.doneDir.trim()
|
const doneDir = value.doneDir.trim()
|
||||||
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
||||||
const databaseType = normalizeDatabaseType((value as any).databaseType)
|
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 databaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||||||
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
|
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
|
||||||
|
|
||||||
@ -1575,14 +1370,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (postgresMode === 'url' && !databaseUrl) {
|
if (!databaseUrl) {
|
||||||
setErr('Bitte Database URL angeben.')
|
setErr('Bitte Database URL angeben.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (postgresMode === 'local' && !postgresDataDir) {
|
|
||||||
setErr('Bitte PostgreSQL-Datenordner angeben.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||||||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||||||
@ -1635,11 +1426,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
databaseType,
|
databaseType,
|
||||||
postgresMode,
|
|
||||||
postgresDataDir,
|
|
||||||
postgresPort,
|
|
||||||
postgresDatabase,
|
|
||||||
postgresUser,
|
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
dbPassword: pendingDbPassword || undefined,
|
dbPassword: pendingDbPassword || undefined,
|
||||||
recordDir,
|
recordDir,
|
||||||
@ -1691,22 +1477,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setValue((v) => ({
|
setValue((v) => ({
|
||||||
...v,
|
...v,
|
||||||
databaseType,
|
databaseType,
|
||||||
postgresMode,
|
|
||||||
postgresDataDir,
|
|
||||||
postgresPort,
|
|
||||||
postgresDatabase,
|
|
||||||
postgresUser,
|
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const databaseChanged =
|
const databaseChanged =
|
||||||
postgresSourceKey({
|
postgresSourceKey({
|
||||||
databaseType,
|
databaseType,
|
||||||
postgresMode,
|
|
||||||
postgresDataDir,
|
|
||||||
postgresPort,
|
|
||||||
postgresDatabase,
|
|
||||||
postgresUser,
|
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
}) !== loadedPostgresSourceKey ||
|
}) !== loadedPostgresSourceKey ||
|
||||||
hadPendingDbPassword
|
hadPendingDbPassword
|
||||||
@ -1716,21 +1492,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
new CustomEvent('models-db-changed', {
|
new CustomEvent('models-db-changed', {
|
||||||
detail: {
|
detail: {
|
||||||
databaseType,
|
databaseType,
|
||||||
postgresMode,
|
|
||||||
postgresDataDir,
|
|
||||||
postgresPort,
|
|
||||||
postgresDatabase,
|
|
||||||
postgresUser,
|
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
setLoadedDatabaseType(databaseType)
|
setLoadedDatabaseType(databaseType)
|
||||||
setLoadedPostgresMode(postgresMode)
|
|
||||||
setLoadedPostgresDataDir(postgresDataDir)
|
|
||||||
setLoadedPostgresPort(postgresPort)
|
|
||||||
setLoadedPostgresDatabase(postgresDatabase)
|
|
||||||
setLoadedPostgresUser(postgresUser)
|
|
||||||
setLoadedDatabaseUrl(databaseUrl)
|
setLoadedDatabaseUrl(databaseUrl)
|
||||||
setPendingDbPassword('')
|
setPendingDbPassword('')
|
||||||
}
|
}
|
||||||
@ -2403,44 +2169,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<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">
|
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||||
Database URL
|
Database URL
|
||||||
@ -2461,65 +2194,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
@ -2600,7 +2264,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
initialUrl={String((value as any).databaseUrl ?? '')}
|
initialUrl={String((value as any).databaseUrl ?? '')}
|
||||||
initialHasPassword={Boolean((value as any).hasDbPassword)}
|
initialHasPassword={Boolean((value as any).hasDbPassword)}
|
||||||
onApply={({ databaseUrl, dbPassword }) => {
|
onApply={({ databaseUrl, dbPassword }) => {
|
||||||
setValue((v) => ({ ...v, postgresMode: 'url', databaseUrl }))
|
setValue((v) => ({ ...v, databaseUrl }))
|
||||||
setPendingDbPassword(dbPassword || '')
|
setPendingDbPassword(dbPassword || '')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user