This commit is contained in:
Linrador 2026-06-24 10:43:31 +02:00
parent aa96f97851
commit c6c6bad6cf
10 changed files with 37 additions and 1202 deletions

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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(&current)
normalizePostgresSettings(&next)
if current.PostgresMode != postgresModeLocal || next.PostgresMode != postgresModeLocal {
return false, nil
}
oldDir, err := localPostgresDataDirAbs(current)
if err != nil {
return false, err
}
newDir, err := localPostgresDataDirAbs(next)
if err != nil {
return false, err
}
oldDir = filepath.Clean(oldDir)
newDir = filepath.Clean(newDir)
if samePath(oldDir, newDir) {
return false, nil
}
oldHasCluster, err := localPostgresDataDirHasCluster(oldDir)
if err != nil {
return false, err
}
if !oldHasCluster {
return false, nil
}
newHasCluster, err := localPostgresDataDirHasCluster(newDir)
if err != nil {
return false, err
}
if newHasCluster {
return false, appErrorf("Ziel enthält bereits einen PostgreSQL-Datenordner: %s", newDir)
}
if exists, empty, err := dirExistsEmpty(newDir); err != nil {
return false, err
} else if exists && !empty {
return false, appErrorf("Zielordner ist nicht leer: %s", newDir)
} else if exists && empty {
if err := os.Remove(newDir); err != nil {
return false, err
}
}
appLogln("PostgreSQL-Datenordner wird verschoben:", oldDir, "->", newDir)
if store := getModelStore(); store != nil {
_ = store.Close()
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := stopLocalPostgresCluster(ctx, oldDir); err != nil {
return false, err
}
if err := moveDir(oldDir, newDir); err != nil {
return false, err
}
appLogln("PostgreSQL-Datenordner verschoben:", newDir)
return true, nil
}
func localPostgresDataDirHasCluster(dataDir string) (bool, error) {
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
if dataDir == "" {
return false, nil
}
versionFile := filepath.Join(dataDir, "PG_VERSION")
info, err := os.Stat(versionFile)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return info != nil && !info.IsDir(), nil
}
func dirExistsEmpty(path string) (bool, bool, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, false, nil
}
return false, false, err
}
if !info.IsDir() {
return true, false, appErrorf("Ziel ist kein Ordner: %s", path)
}
entries, err := os.ReadDir(path)
if err != nil {
return true, false, err
}
return true, len(entries) == 0, nil
}
func samePath(a, b string) bool {
aa, errA := filepath.Abs(filepath.Clean(a))
bb, errB := filepath.Abs(filepath.Clean(b))
if errA == nil {
a = aa
}
if errB == nil {
b = bb
}
if strings.EqualFold(a, b) {
return true
}
return a == b
}
func stopLocalPostgresCluster(ctx context.Context, dataDir string) error {
pgCtl, err := postgresToolPath("pg_ctl")
if err != nil {
return appErrorf("pg_ctl wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm pg_ctl in PATH auf.")
}
out, err := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "-w", "stop", "-m", "fast")
if err == nil {
return nil
}
lower := strings.ToLower(out)
if strings.Contains(lower, "no server running") || strings.Contains(lower, "pid file") && strings.Contains(lower, "does not exist") {
return nil
}
return appErrorf("lokaler PostgreSQL-Server konnte nicht gestoppt werden: %s", out)
}
func moveDir(src, dst string) error {
src = filepath.Clean(src)
dst = filepath.Clean(dst)
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
if err := os.Rename(src, dst); err == nil {
return nil
}
tmp := dst + ".moving-" + strconv.FormatInt(time.Now().UnixNano(), 10)
if err := copyDir(src, tmp); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.RemoveAll(tmp)
return err
}
return os.RemoveAll(src)
}
func copyDir(src, dst string) error {
src = filepath.Clean(src)
dst = filepath.Clean(dst)
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode()
if entry.IsDir() {
return os.MkdirAll(target, mode.Perm())
}
if mode&os.ModeSymlink != 0 {
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(linkTarget, target)
}
return copyRegularFile(path, target, mode.Perm())
})
}
func copyRegularFile(src, dst string, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func ensureLocalPostgresCluster(ctx context.Context, dataDir string, port int, user, database string) error {
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
}

View 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)
}

View File

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

View File

@ -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: "",
@ -291,14 +281,9 @@ type RecorderSettingsPublic struct {
DoneDir string `json:"doneDir"` DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
DatabaseType string `json:"databaseType"` DatabaseType string `json:"databaseType"`
PostgresMode string `json:"postgresMode"` DatabaseURL string `json:"databaseUrl"`
PostgresDataDir string `json:"postgresDataDir"` HasDBPassword bool `json:"hasDbPassword"`
PostgresPort int `json:"postgresPort"`
PostgresDatabase string `json:"postgresDatabase"`
PostgresUser string `json:"postgresUser"`
DatabaseURL string `json:"databaseUrl"`
HasDBPassword bool `json:"hasDbPassword"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"` AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"` AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
@ -342,14 +327,9 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
DoneDir: s.DoneDir, DoneDir: s.DoneDir,
FFmpegPath: s.FFmpegPath, FFmpegPath: s.FFmpegPath,
DatabaseType: normalizeDatabaseType(s.DatabaseType), DatabaseType: normalizeDatabaseType(s.DatabaseType),
PostgresMode: s.PostgresMode, DatabaseURL: strings.TrimSpace(s.DatabaseURL),
PostgresDataDir: s.PostgresDataDir, HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
PostgresPort: s.PostgresPort,
PostgresDatabase: s.PostgresDatabase,
PostgresUser: s.PostgresUser,
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
AutoAddToDownloadList: s.AutoAddToDownloadList, AutoAddToDownloadList: s.AutoAddToDownloadList,
AutoStartAddedDownloads: s.AutoStartAddedDownloads, AutoStartAddedDownloads: s.AutoStartAddedDownloads,
@ -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().

View File

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

View File

@ -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 || '')
}} }}
/> />