moved to sqlite

This commit is contained in:
Linrador 2026-06-23 14:38:06 +02:00
parent c473b2f39c
commit 1800d98eff
13 changed files with 744 additions and 100 deletions

View File

@ -257,11 +257,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,
generated/, record/ und record/done im User-Bereich. nsfwapp.sqlite, 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.
pg_dump/pg_restore werden fuer Datenbank-Backup und Restore benoetigt. SQLite ist die Standard-Datenbank. pg_dump/pg_restore werden nur fuer Postgres-Backup und Restore benoetigt.
Der AI-Server braucht Python und die benoetigten Python-Pakete deiner ML-Umgebung. Der AI-Server braucht Python und die benoetigten Python-Pakete deiner ML-Umgebung.
EOF EOF
@ -272,7 +272,7 @@ Section: $SECTION
Priority: $PRIORITY Priority: $PRIORITY
Architecture: $ARCH Architecture: $ARCH
Maintainer: $MAINTAINER Maintainer: $MAINTAINER
Depends: ca-certificates, ffmpeg, python3, postgresql-client, xdg-utils, libnotify-bin, procps Depends: ca-certificates, ffmpeg, python3, xdg-utils, libnotify-bin, procps
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

@ -14,10 +14,12 @@ require (
github.com/r3labs/sse/v2 v2.10.0 github.com/r3labs/sse/v2 v2.10.0
github.com/yalue/onnxruntime_go v1.27.0 github.com/yalue/onnxruntime_go v1.27.0
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
modernc.org/sqlite v1.53.0
) )
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
@ -35,9 +37,12 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tinylib/msgp v1.6.4 // indirect github.com/tinylib/msgp v1.6.4 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
@ -47,6 +52,9 @@ require (
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
require ( require (
@ -56,5 +64,5 @@ require (
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
golang.org/x/image v0.37.0 golang.org/x/image v0.37.0
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
) )

View File

@ -9,6 +9,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
@ -62,6 +64,10 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@ -74,6 +80,8 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
@ -149,6 +157,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -157,6 +166,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -192,3 +203,11 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=

View File

@ -0,0 +1,60 @@
package main
import (
"path/filepath"
"strings"
)
const (
databaseTypeSQLite = "sqlite"
databaseTypePostgres = "postgres"
defaultSQLitePath = "nsfwapp.sqlite"
)
type ModelStoreConfig struct {
Type string
DSN string
SQLitePath string
}
func normalizeDatabaseType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case databaseTypePostgres:
return databaseTypePostgres
default:
return databaseTypeSQLite
}
}
func normalizeSQLitePath(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
value = defaultSQLitePath
}
return filepath.Clean(filepath.FromSlash(value))
}
func buildModelStoreConfigFromSettings() (ModelStoreConfig, error) {
return buildModelStoreConfigFromRecorderSettings(getSettings())
}
func buildModelStoreConfigFromRecorderSettings(s RecorderSettings) (ModelStoreConfig, error) {
dbType := normalizeDatabaseType(s.DatabaseType)
if dbType == databaseTypePostgres {
dsn, err := buildPostgresDSNFromRecorderSettings(s)
if err != nil {
return ModelStoreConfig{}, err
}
return ModelStoreConfig{Type: databaseTypePostgres, DSN: dsn}, nil
}
sqlitePath, err := resolvePathRelativeToApp(normalizeSQLitePath(s.SQLitePath))
if err != nil {
return ModelStoreConfig{}, err
}
return ModelStoreConfig{
Type: databaseTypeSQLite,
DSN: sqlitePath,
SQLitePath: sqlitePath,
}, nil
}

View File

@ -6,13 +6,17 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
) )
type StoredModel struct { type StoredModel struct {
@ -91,7 +95,7 @@ type ModelFlagsPatch struct {
} }
type ModelStore struct { type ModelStore struct {
dsn string config ModelStoreConfig
db *sql.DB db *sql.DB
initOnce sync.Once initOnce sync.Once
@ -115,6 +119,73 @@ func fmtNullTime(nt sql.NullTime) string {
return nt.Time.UTC().Format(time.RFC3339Nano) return nt.Time.UTC().Format(time.RFC3339Nano)
} }
type dbNullTime struct {
sql.NullTime
}
func (nt *dbNullTime) Scan(value any) error {
if value == nil {
nt.Valid = false
nt.Time = time.Time{}
return nil
}
switch v := value.(type) {
case time.Time:
nt.Valid = true
nt.Time = v.UTC()
return nil
case string:
return nt.scanString(v)
case []byte:
return nt.scanString(string(v))
default:
var raw sql.NullTime
if err := raw.Scan(value); err != nil {
return err
}
nt.NullTime = raw
if nt.Valid {
nt.Time = nt.Time.UTC()
}
return nil
}
}
func (nt *dbNullTime) scanString(value string) error {
value = strings.TrimSpace(value)
if value == "" {
nt.Valid = false
nt.Time = time.Time{}
return nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05.999999Z07:00",
"2006-01-02 15:04:05Z07:00",
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05.999999 -0700 MST",
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05.999999",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, value); err == nil {
nt.Valid = true
nt.Time = t.UTC()
return nil
}
}
return fmt.Errorf("invalid time value %q", value)
}
func nullableTimeArg(nt sql.NullTime) any { func nullableTimeArg(nt sql.NullTime) any {
if !nt.Valid { if !nt.Valid {
return nil return nil
@ -154,12 +225,22 @@ func parseRFC3339Nano(s string) sql.NullTime {
return sql.NullTime{Valid: false} return sql.NullTime{Valid: false}
} }
func NewModelStore(dsn string) *ModelStore { func NewModelStore(config ModelStoreConfig) *ModelStore {
return &ModelStore{dsn: strings.TrimSpace(dsn)} config.Type = normalizeDatabaseType(config.Type)
config.DSN = strings.TrimSpace(config.DSN)
config.SQLitePath = strings.TrimSpace(config.SQLitePath)
return &ModelStore{config: config}
} }
func (s *ModelStore) Load() error { return s.ensureInit() } func (s *ModelStore) Load() error { return s.ensureInit() }
func (s *ModelStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
func (s *ModelStore) ensureInit() error { func (s *ModelStore) ensureInit() error {
s.initOnce.Do(func() { s.initOnce.Do(func() {
s.initErr = s.init() s.initErr = s.init()
@ -168,26 +249,61 @@ func (s *ModelStore) ensureInit() error {
} }
func (s *ModelStore) init() error { func (s *ModelStore) init() error {
if strings.TrimSpace(s.dsn) == "" { config := s.config
config.Type = normalizeDatabaseType(config.Type)
driverName := "pgx"
dsn := strings.TrimSpace(config.DSN)
if config.Type == databaseTypeSQLite {
sqlitePath := strings.TrimSpace(config.SQLitePath)
if sqlitePath == "" {
sqlitePath = dsn
}
if strings.TrimSpace(sqlitePath) == "" {
return errors.New("sqlite path fehlt")
}
info, err := os.Stat(sqlitePath)
if err == nil && info.IsDir() {
return fmt.Errorf("sqlite path ist ein ordner: %s", sqlitePath)
}
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0755); err != nil {
return err
}
driverName = "sqlite"
dsn = sqlitePath
config.SQLitePath = sqlitePath
}
if strings.TrimSpace(dsn) == "" {
return errors.New("db dsn fehlt") return errors.New("db dsn fehlt")
} }
db, err := sql.Open("pgx", s.dsn) db, err := sql.Open(driverName, dsn)
if err != nil { if err != nil {
return err return err
} }
db.SetMaxOpenConns(10) if config.Type == databaseTypeSQLite {
db.SetMaxIdleConns(10) db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
} else {
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
}
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
_ = db.Close() _ = db.Close()
return err return err
} }
// ✅ Du hast die Tabelle schon in Postgres angelegt (mit richtigen Typen).
// Deshalb hier KEIN create/alter mehr, sonst riskierst du falsche Typen.
s.db = db s.db = db
s.config = config
if config.Type == databaseTypeSQLite {
if err := s.ensureSQLiteSchema(); err != nil {
_ = db.Close()
return err
}
}
if err := s.normalizeNameOnlyChaturbate(); err != nil { if err := s.normalizeNameOnlyChaturbate(); err != nil {
return err return err
@ -196,6 +312,77 @@ func (s *ModelStore) init() error {
return nil return nil
} }
func (s *ModelStore) isSQLite() bool {
return s != nil && normalizeDatabaseType(s.config.Type) == databaseTypeSQLite
}
func (s *ModelStore) blobLengthExpr() string {
if s.isSQLite() {
return "length(profile_image_blob)"
}
return "octet_length(profile_image_blob)"
}
func (s *ModelStore) emptyTextExpr() string {
if s.isSQLite() {
return "''"
}
return "''::text"
}
func (s *ModelStore) ensureSQLiteSchema() error {
stmts := []string{
`PRAGMA busy_timeout = 5000;`,
`PRAGMA foreign_keys = ON;`,
`PRAGMA journal_mode = WAL;`,
`CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
input TEXT NOT NULL DEFAULT '',
is_url BOOLEAN NOT NULL DEFAULT 0,
host TEXT DEFAULT '',
path TEXT DEFAULT '',
model_key TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '',
last_stream DATETIME NULL,
last_seen_online BOOLEAN NULL,
last_seen_online_at DATETIME NULL,
cb_online_json TEXT DEFAULT '',
cb_online_fetched_at DATETIME NULL,
cb_online_last_error TEXT DEFAULT '',
profile_image_url TEXT DEFAULT '',
profile_image_mime TEXT DEFAULT '',
profile_image_blob BLOB NULL,
profile_image_updated_at DATETIME NULL,
room_status TEXT DEFAULT '',
is_online BOOLEAN NOT NULL DEFAULT 0,
chat_room_url TEXT DEFAULT '',
image_url TEXT DEFAULT '',
last_online_at DATETIME NULL,
last_offline_at DATETIME NULL,
last_room_sync_at DATETIME NULL,
biocontext_json TEXT DEFAULT '',
biocontext_fetched_at DATETIME NULL,
watching BOOLEAN NOT NULL DEFAULT 0,
favorite BOOLEAN NOT NULL DEFAULT 0,
hot BOOLEAN NOT NULL DEFAULT 0,
keep BOOLEAN NOT NULL DEFAULT 0,
liked BOOLEAN NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`,
`CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *ModelStore) SetChaturbateRoomState( func (s *ModelStore) SetChaturbateRoomState(
host string, host string,
modelKey string, modelKey string,
@ -672,7 +859,7 @@ func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetch
} }
var js sql.NullString var js sql.NullString
var ts sql.NullTime var ts dbNullTime
err = s.db.QueryRow(` err = s.db.QueryRow(`
SELECT biocontext_json, biocontext_fetched_at SELECT biocontext_json, biocontext_fetched_at
FROM models FROM models
@ -690,9 +877,9 @@ LIMIT 1;
val := strings.TrimSpace(js.String) val := strings.TrimSpace(js.String)
if val == "" { if val == "" {
return "", fmtNullTime(ts), false, nil return "", fmtNullTime(ts.NullTime), false, nil
} }
return val, fmtNullTime(ts), true, nil return val, fmtNullTime(ts.NullTime), true, nil
} }
func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error { func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error {
@ -799,12 +986,12 @@ WHERE is_url = false
type rowT struct { type rowT struct {
oldID, key, tags string oldID, key, tags string
lastStream sql.NullTime lastStream dbNullTime
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
createdAt, updatedAt sql.NullTime createdAt, updatedAt dbNullTime
} }
var items []rowT var items []rowT
@ -890,7 +1077,7 @@ LIMIT 1;
likedArg = nil likedArg = nil
} }
lastStreamArg := nullableTimeArg(it.lastStream) lastStreamArg := nullableTimeArg(it.lastStream.NullTime)
if targetID == "" { if targetID == "" {
targetID = canonicalID(host, it.key) targetID = canonicalID(host, it.key)
@ -912,7 +1099,7 @@ INSERT INTO models (
return err return err
} }
} else { } else {
_, err = tx.Exec(` updateSQL := `
UPDATE models SET UPDATE models SET
input = CASE input = CASE
WHEN is_url=false OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key)) WHEN is_url=false OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key))
@ -940,7 +1127,12 @@ UPDATE models SET
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
WHERE id = $13; WHERE id = $13;
`, `
if s.isSQLite() {
updateSQL = strings.ReplaceAll(updateSQL, "$6::timestamptz", "$6")
updateSQL = strings.ReplaceAll(updateSQL, "$11::boolean", "$11")
}
_, err = tx.Exec(updateSQL,
newInput, host, newPath, newInput, host, newPath,
it.tags, it.tags, it.tags, it.tags,
lastStreamArg, lastStreamArg,
@ -969,7 +1161,10 @@ func (s *ModelStore) List() []StoredModel {
return []StoredModel{} return []StoredModel{}
} }
q1 := ` blobLengthExpr := s.blobLengthExpr()
emptyTextExpr := s.emptyTextExpr()
q1 := fmt.Sprintf(`
SELECT SELECT
id, id,
COALESCE(input,'') as input, COALESCE(input,'') as input,
@ -986,7 +1181,7 @@ SELECT
COALESCE(cb_online_last_error,''), COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status, COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online, COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url, COALESCE(chat_room_url,'') as chat_room_url,
@ -998,9 +1193,9 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
` `, blobLengthExpr)
q2 := ` q2 := fmt.Sprintf(`
SELECT SELECT
id, id,
COALESCE(input,'') as input, COALESCE(input,'') as input,
@ -1014,10 +1209,10 @@ SELECT
last_seen_online_at, last_seen_online_at,
COALESCE(cb_online_json,''), COALESCE(cb_online_json,''),
cb_online_fetched_at, cb_online_fetched_at,
''::text as cb_online_last_error, %s as cb_online_last_error,
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status, COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online, COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url, COALESCE(chat_room_url,'') as chat_room_url,
@ -1029,7 +1224,7 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
` `, emptyTextExpr, blobLengthExpr)
rows, err := s.db.Query(q1) rows, err := s.db.Query(q1)
if err != nil { if err != nil {
@ -1050,31 +1245,31 @@ ORDER BY updated_at DESC;
id, input, host, path, modelKey, tags string id, input, host, path, modelKey, tags string
isURL bool isURL bool
lastStream sql.NullTime lastStream dbNullTime
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt dbNullTime
cbOnlineJSON string cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime cbOnlineFetchedAt dbNullTime
cbOnlineLastError string cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt dbNullTime
hasProfileImage int64 hasProfileImage int64
roomStatus string roomStatus string
isOnline bool isOnline bool
chatRoomURL string chatRoomURL string
imageURL string imageURL string
lastOnlineAt sql.NullTime lastOnlineAt dbNullTime
lastOfflineAt sql.NullTime lastOfflineAt dbNullTime
lastRoomSyncAt sql.NullTime lastRoomSyncAt dbNullTime
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
createdAt, updatedAt time.Time createdAt, updatedAt dbNullTime
) )
if err := rows.Scan( if err := rows.Scan(
@ -1111,24 +1306,24 @@ ORDER BY updated_at DESC;
Gender: gender, Gender: gender,
Country: country, Country: country,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream.NullTime),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime),
CbOnlineJSON: cbOnlineJSON, CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt), CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime),
CbOnlineLastError: cbOnlineLastError, CbOnlineLastError: cbOnlineLastError,
ProfileImageURL: profileImageURL, ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime),
RoomStatus: roomStatus, RoomStatus: roomStatus,
IsOnline: isOnline, IsOnline: isOnline,
ChatRoomURL: chatRoomURL, ChatRoomURL: chatRoomURL,
ImageURL: imageURL, ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt), LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime),
LastOfflineAt: fmtNullTime(lastOfflineAt), LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt), LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime),
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
@ -1136,8 +1331,8 @@ ORDER BY updated_at DESC;
Keep: keep, Keep: keep,
Liked: ptrLikedFromNullBool(liked), Liked: ptrLikedFromNullBool(liked),
CreatedAt: fmtTime(createdAt), CreatedAt: fmtNullTime(createdAt.NullTime),
UpdatedAt: fmtTime(updatedAt), UpdatedAt: fmtNullTime(updatedAt.NullTime),
} }
if hasProfileImage != 0 { if hasProfileImage != 0 {
@ -1156,12 +1351,12 @@ func (s *ModelStore) Meta() ModelsMeta {
} }
var count int var count int
var updatedAt sql.NullTime var updatedAt dbNullTime
err := s.db.QueryRow(`SELECT COUNT(*), MAX(updated_at) FROM models;`).Scan(&count, &updatedAt) err := s.db.QueryRow(`SELECT COUNT(*), MAX(updated_at) FROM models;`).Scan(&count, &updatedAt)
if err != nil { if err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""} return ModelsMeta{Count: 0, UpdatedAt: ""}
} }
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)} return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt.NullTime)}
} }
type ChaturbateOnlineSnapshot struct { type ChaturbateOnlineSnapshot struct {
@ -1493,7 +1688,7 @@ func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*Chatur
} }
var js sql.NullString var js sql.NullString
var fetchedAt sql.NullTime var fetchedAt dbNullTime
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT cb_online_json, cb_online_fetched_at SELECT cb_online_json, cb_online_fetched_at
@ -1512,15 +1707,15 @@ LIMIT 1;
raw := strings.TrimSpace(js.String) raw := strings.TrimSpace(js.String)
if raw == "" { if raw == "" {
return nil, fmtNullTime(fetchedAt), false, nil return nil, fmtNullTime(fetchedAt.NullTime), false, nil
} }
var snap ChaturbateOnlineSnapshot var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil { if err := json.Unmarshal([]byte(raw), &snap); err != nil {
return nil, fmtNullTime(fetchedAt), false, err return nil, fmtNullTime(fetchedAt.NullTime), false, err
} }
return &snap, fmtNullTime(fetchedAt), true, nil return &snap, fmtNullTime(fetchedAt.NullTime), true, nil
} }
func nullableStringArg(s string) any { func nullableStringArg(s string) any {
@ -1802,35 +1997,37 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
isURL bool isURL bool
lastStream sql.NullTime lastStream dbNullTime
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt dbNullTime
cbOnlineJSON string cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime cbOnlineFetchedAt dbNullTime
cbOnlineLastError string cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt dbNullTime
hasProfileImage int64 hasProfileImage int64
roomStatus string roomStatus string
isOnline bool isOnline bool
chatRoomURL string chatRoomURL string
imageURL string imageURL string
lastOnlineAt sql.NullTime lastOnlineAt dbNullTime
lastOfflineAt sql.NullTime lastOfflineAt dbNullTime
lastRoomSyncAt sql.NullTime lastRoomSyncAt dbNullTime
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
createdAt, updatedAt time.Time createdAt, updatedAt dbNullTime
) )
blobLengthExpr := s.blobLengthExpr()
// q1: mit optionaler Spalte cb_online_last_error // q1: mit optionaler Spalte cb_online_last_error
q1 := ` q1 := fmt.Sprintf(`
SELECT SELECT
COALESCE(input,'') as input, COALESCE(input,'') as input,
is_url, is_url,
@ -1846,7 +2043,7 @@ SELECT
COALESCE(cb_online_last_error,''), COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status, COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online, COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url, COALESCE(chat_room_url,'') as chat_room_url,
@ -1858,11 +2055,11 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
WHERE id=$1; WHERE id=$1;
` `, blobLengthExpr)
// q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert) // q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
// Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten. // Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
q2 := ` q2 := fmt.Sprintf(`
SELECT SELECT
COALESCE(input,'') as input, COALESCE(input,'') as input,
is_url, is_url,
@ -1878,7 +2075,7 @@ SELECT
'' as cb_online_last_error, '' as cb_online_last_error,
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status, COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online, COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url, COALESCE(chat_room_url,'') as chat_room_url,
@ -1890,7 +2087,7 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
WHERE id=$1; WHERE id=$1;
` `, blobLengthExpr)
scan := func(q string) error { scan := func(q string) error {
return s.db.QueryRow(q, id).Scan( return s.db.QueryRow(q, id).Scan(
@ -1946,21 +2143,21 @@ WHERE id=$1;
Gender: gender, Gender: gender,
Country: country, Country: country,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream.NullTime),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime),
CbOnlineJSON: cbOnlineJSON, CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt), CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime),
CbOnlineLastError: cbOnlineLastError, CbOnlineLastError: cbOnlineLastError,
RoomStatus: roomStatus, RoomStatus: roomStatus,
IsOnline: isOnline, IsOnline: isOnline,
ChatRoomURL: chatRoomURL, ChatRoomURL: chatRoomURL,
ImageURL: imageURL, ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt), LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime),
LastOfflineAt: fmtNullTime(lastOfflineAt), LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt), LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime),
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
@ -1968,11 +2165,11 @@ WHERE id=$1;
Keep: keep, Keep: keep,
Liked: ptrLikedFromNullBool(liked), Liked: ptrLikedFromNullBool(liked),
CreatedAt: fmtTime(createdAt), CreatedAt: fmtNullTime(createdAt.NullTime),
UpdatedAt: fmtTime(updatedAt), UpdatedAt: fmtNullTime(updatedAt.NullTime),
ProfileImageURL: profileImageURL, ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime),
} }
if hasProfileImage != 0 { if hasProfileImage != 0 {

View File

@ -0,0 +1,53 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestModelStoreSQLiteCreatesSchemaAndPersists(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "models.sqlite")
store := NewModelStore(ModelStoreConfig{
Type: databaseTypeSQLite,
DSN: dbPath,
SQLitePath: dbPath,
})
defer store.Close()
if err := store.Load(); err != nil {
t.Fatalf("load sqlite store: %v", err)
}
if _, err := os.Stat(dbPath); err != nil {
t.Fatalf("sqlite file not created: %v", err)
}
model, err := store.EnsureByHostModelKey("chaturbate.com", "alice")
if err != nil {
t.Fatalf("ensure model: %v", err)
}
if model.ID != "chaturbate.com:alice" {
t.Fatalf("unexpected model id: %q", model.ID)
}
if err := store.SetLastSeenOnline("chaturbate.com", "alice", true, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatalf("set last seen online: %v", err)
}
models := store.List()
if len(models) != 1 {
t.Fatalf("expected 1 model, got %d", len(models))
}
if models[0].LastSeenOnline == nil || !*models[0].LastSeenOnline {
t.Fatalf("lastSeenOnline not persisted: %#v", models[0].LastSeenOnline)
}
meta := store.Meta()
if meta.Count != 1 {
t.Fatalf("expected meta count 1, got %d", meta.Count)
}
if meta.UpdatedAt == "" {
t.Fatal("expected meta updatedAt")
}
}

View File

@ -119,17 +119,20 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/tasks/check-videos", checkVideosTaskHandler) api.HandleFunc("/api/tasks/check-videos", checkVideosTaskHandler)
// -------------------------- // --------------------------
// 3) ModelStore (Postgres) // 3) ModelStore
// DSN kommt aus Settings: databaseUrl + gespeichertes Passwort
// -------------------------- // --------------------------
dsn, err := buildPostgresDSNFromSettings() storeConfig, err := buildModelStoreConfigFromSettings()
if err != nil { if err != nil {
appLogln("⚠️ models DSN:", err) appLogln("models DB config:", err)
}
if storeConfig.Type == databaseTypePostgres {
appLogln("Models DB (Postgres):", sanitizeDSNForLog(storeConfig.DSN))
} else {
appLogln("Models DB (SQLite):", storeConfig.SQLitePath)
} }
appLogln("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn))
store := NewModelStore(dsn) store := NewModelStore(storeConfig)
if err := store.Load(); err != nil { if err := store.Load(); err != nil {
appLogln("⚠️ models load:", err) appLogln("⚠️ models load:", err)
} }

View File

@ -14,6 +14,8 @@ import (
) )
type RecorderSettings struct { type RecorderSettings struct {
DatabaseType string `json:"databaseType"` // sqlite | postgres
SQLitePath string `json:"sqlitePath"`
DatabaseURL string `json:"databaseUrl"` DatabaseURL string `json:"databaseUrl"`
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext) EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
@ -65,6 +67,8 @@ type RecorderSettings struct {
var ( var (
settingsMu sync.Mutex settingsMu sync.Mutex
settings = RecorderSettings{ settings = RecorderSettings{
DatabaseType: databaseTypeSQLite,
SQLitePath: defaultSQLitePath,
DatabaseURL: "", DatabaseURL: "",
EncryptedDBPassword: "", EncryptedDBPassword: "",
@ -160,6 +164,8 @@ func loadSettings() {
if json.Unmarshal(b, &s) == nil { if json.Unmarshal(b, &s) == nil {
s.RecordDir = normalizeSettingsDir(s.RecordDir, defaultRecordDir, legacyDefaultRecordDir) s.RecordDir = normalizeSettingsDir(s.RecordDir, defaultRecordDir, legacyDefaultRecordDir)
s.DoneDir = normalizeSettingsDir(s.DoneDir, defaultDoneDir, legacyDefaultDoneDir) s.DoneDir = normalizeSettingsDir(s.DoneDir, defaultDoneDir, legacyDefaultDoneDir)
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
s.SQLitePath = normalizeSQLitePath(s.SQLitePath)
if strings.TrimSpace(s.FFmpegPath) != "" { if strings.TrimSpace(s.FFmpegPath) != "" {
s.FFmpegPath = strings.TrimSpace(s.FFmpegPath) s.FFmpegPath = strings.TrimSpace(s.FFmpegPath)
} }
@ -206,6 +212,8 @@ func loadSettings() {
settingsMu.Unlock() settingsMu.Unlock()
} }
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
s.SQLitePath = normalizeSQLitePath(s.SQLitePath)
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL) s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
// Optional: falls in der JSON mal ein URL MIT Passwort steht (Altbestand) // Optional: falls in der JSON mal ein URL MIT Passwort steht (Altbestand)
@ -275,6 +283,8 @@ type RecorderSettingsPublic struct {
DoneDir string `json:"doneDir"` DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
DatabaseType string `json:"databaseType"`
SQLitePath string `json:"sqlitePath"`
DatabaseURL string `json:"databaseUrl"` DatabaseURL string `json:"databaseUrl"`
HasDBPassword bool `json:"hasDbPassword"` HasDBPassword bool `json:"hasDbPassword"`
@ -319,6 +329,8 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
DoneDir: s.DoneDir, DoneDir: s.DoneDir,
FFmpegPath: s.FFmpegPath, FFmpegPath: s.FFmpegPath,
DatabaseType: normalizeDatabaseType(s.DatabaseType),
SQLitePath: normalizeSQLitePath(s.SQLitePath),
DatabaseURL: strings.TrimSpace(s.DatabaseURL), DatabaseURL: strings.TrimSpace(s.DatabaseURL),
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "", HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
@ -459,6 +471,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
// --- DB URL + Passwort behandeln --- // --- DB URL + Passwort behandeln ---
// 1) Trim // 1) Trim
in.DatabaseType = normalizeDatabaseType(in.DatabaseType)
in.SQLitePath = normalizeSQLitePath(in.SQLitePath)
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL) in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren // 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
@ -499,6 +513,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.DoneDir = in.DoneDir next.DoneDir = in.DoneDir
next.FFmpegPath = in.FFmpegPath next.FFmpegPath = in.FFmpegPath
next.DatabaseType = in.DatabaseType
next.SQLitePath = in.SQLitePath
next.DatabaseURL = in.DatabaseURL next.DatabaseURL = in.DatabaseURL
next.EncryptedDBPassword = in.EncryptedDBPassword next.EncryptedDBPassword = in.EncryptedDBPassword
@ -537,20 +553,22 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
dbChanged := dbChanged :=
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
normalizeSQLitePath(next.SQLitePath) != normalizeSQLitePath(current.SQLitePath) ||
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
var newStore *ModelStore var newStore *ModelStore
// DB erst prüfen, BEVOR gespeichert wird // DB erst prüfen, BEVOR gespeichert wird
if dbChanged { if dbChanged {
dsn, err := buildPostgresDSNFromRecorderSettings(next) storeConfig, err := buildModelStoreConfigFromRecorderSettings(next)
if err != nil { if err != nil {
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest) http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
return return
} }
newStore = NewModelStore(dsn) newStore = NewModelStore(storeConfig)
if err := newStore.Load(); err != nil { if err := newStore.Load(); err != nil {
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest) http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
return return

View File

@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -126,6 +127,16 @@ func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
storeConfig, err := buildModelStoreConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if storeConfig.Type == databaseTypeSQLite {
settingsSQLiteDatabaseBackupHandler(w, r, storeConfig)
return
}
cfg, _, err := postgresToolConfigFromSettings() cfg, _, err := postgresToolConfigFromSettings()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -203,13 +214,65 @@ func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, filename, info.ModTime(), f) http.ServeContent(w, r, filename, info.ModTime(), f)
} }
func settingsSQLiteDatabaseBackupHandler(w http.ResponseWriter, r *http.Request, storeConfig ModelStoreConfig) {
tmp, err := os.CreateTemp("", "nsfwapp-db-backup-*.sqlite")
if err != nil {
http.Error(w, "Backup-Datei konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError)
return
}
tmpPath := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpPath)
defer os.Remove(tmpPath)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
db, err := sql.Open("sqlite", storeConfig.SQLitePath)
if err != nil {
http.Error(w, "SQLite konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer db.Close()
if _, err := db.ExecContext(ctx, `PRAGMA busy_timeout = 5000;`); err != nil {
http.Error(w, "SQLite Backup konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := db.ExecContext(ctx, "VACUUM INTO "+sqliteLiteral(tmpPath)); err != nil {
http.Error(w, "SQLite Backup fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
info, err := os.Stat(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
f, err := os.Open(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
filename := "nsfwapp-db-backup-" + time.Now().Format("20060102-150405") + ".sqlite"
w.Header().Set("Content-Type", "application/vnd.sqlite3")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
http.ServeContent(w, r, filename, info.ModTime(), f)
}
func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) { func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return return
} }
cfg, dsn, err := postgresToolConfigFromSettings() storeConfig, err := buildModelStoreConfigFromSettings()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -254,6 +317,33 @@ func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if storeConfig.Type == databaseTypeSQLite {
if err := restoreSQLiteDatabase(storeConfig, tmpPath); err != nil {
http.Error(w, "SQLite Restore fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
refreshedStore := NewModelStore(storeConfig)
if err := refreshedStore.Load(); err != nil {
appLogln("Models DB nach Restore konnte nicht neu verbunden werden:", err)
} else {
setModelStore(refreshedStore)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"fileName": strings.TrimSpace(header.Filename),
"bytes": written,
})
return
}
cfg, dsn, err := postgresToolConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
defer cancel() defer cancel()
@ -274,7 +364,7 @@ func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
refreshedStore := NewModelStore(dsn) refreshedStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: dsn})
if err := refreshedStore.Load(); err != nil { if err := refreshedStore.Load(); err != nil {
appLogln("⚠️ Models DB nach Restore konnte nicht neu verbunden werden:", err) appLogln("⚠️ Models DB nach Restore konnte nicht neu verbunden werden:", err)
} else { } else {
@ -287,3 +377,88 @@ func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
"bytes": written, "bytes": written,
}) })
} }
func restoreSQLiteDatabase(storeConfig ModelStoreConfig, restorePath string) error {
restorePath = filepath.Clean(strings.TrimSpace(restorePath))
if restorePath == "" {
return errors.New("restore path fehlt")
}
if err := validateSQLiteDatabaseFile(restorePath); err != nil {
return err
}
targetPath := filepath.Clean(strings.TrimSpace(storeConfig.SQLitePath))
if targetPath == "" {
return errors.New("sqlite path fehlt")
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
oldStore := getModelStore()
if oldStore != nil && oldStore.isSQLite() && oldStore.db != nil {
_, _ = oldStore.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
_ = oldStore.Close()
}
if _, err := os.Stat(targetPath); err == nil {
backupPath := targetPath + ".before-restore-" + time.Now().Format("20060102-150405") + ".bak"
if _, err := copyDatabaseFile(targetPath, backupPath); err != nil {
return appErrorf("Sicherung der bisherigen SQLite-Datei fehlgeschlagen: %w", err)
}
}
_ = os.Remove(targetPath + "-wal")
_ = os.Remove(targetPath + "-shm")
_ = os.Remove(targetPath)
if _, err := copyDatabaseFile(restorePath, targetPath); err != nil {
return err
}
return nil
}
func validateSQLiteDatabaseFile(path string) error {
db, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer db.Close()
if err := db.Ping(); err != nil {
return err
}
var name string
err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='models' LIMIT 1;`).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("SQLite-Datei enthaelt keine models-Tabelle")
}
return err
}
func copyDatabaseFile(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return 0, err
}
written, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return written, copyErr
}
if closeErr != nil {
return written, closeErr
}
return written, nil
}
func sqliteLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}

View File

@ -13,6 +13,8 @@ import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid
import { startRegistration } from '@simplewebauthn/browser' import { startRegistration } from '@simplewebauthn/browser'
type RecorderSettings = { type RecorderSettings = {
databaseType?: 'sqlite' | 'postgres'
sqlitePath?: string
databaseUrl?: string databaseUrl?: string
hasDbPassword?: boolean hasDbPassword?: boolean
recordDir: string recordDir: string
@ -279,6 +281,8 @@ function SettingsSection(props: {
} }
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
databaseType: 'sqlite',
sqlitePath: 'nsfwapp.sqlite',
databaseUrl: '', databaseUrl: '',
hasDbPassword: false, hasDbPassword: false,
recordDir: 'record', recordDir: 'record',
@ -534,6 +538,15 @@ function databaseBackupFilename(contentDisposition: string | null) {
return plain?.[1]?.trim() || 'nsfwapp-db-backup.dump' return plain?.[1]?.trim() || 'nsfwapp-db-backup.dump'
} }
function normalizeDatabaseType(value: unknown): 'sqlite' | 'postgres' {
return String(value ?? '').trim().toLowerCase() === 'postgres' ? 'postgres' : 'sqlite'
}
function normalizeSqlitePath(value: unknown) {
const path = String(value ?? '').trim()
return path || 'nsfwapp.sqlite'
}
function formatDatabaseBackupBytes(bytes: unknown) { function formatDatabaseBackupBytes(bytes: unknown) {
const n = Number(bytes) const n = Number(bytes)
if (!Number.isFinite(n) || n <= 0) return '' if (!Number.isFinite(n) || n <= 0) return ''
@ -684,6 +697,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [appLogLoading, setAppLogLoading] = useState(false) const [appLogLoading, setAppLogLoading] = useState(false)
const [appLogOpen, setAppLogOpen] = useState(false) const [appLogOpen, setAppLogOpen] = useState(false)
const [dbModalOpen, setDbModalOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'sqlite' | 'postgres'>('sqlite')
const [loadedSqlitePath, setLoadedSqlitePath] = useState('nsfwapp.sqlite')
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)
@ -693,9 +708,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
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 currentDatabaseType = normalizeDatabaseType((value as any).databaseType)
const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath)
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
const databaseSettingsDirty = const databaseSettingsDirty =
currentDatabaseUrl !== loadedDatabaseUrl || Boolean((pendingDbPassword || '').trim()) currentDatabaseType !== loadedDatabaseType ||
currentSqlitePath !== loadedSqlitePath ||
currentDatabaseUrl !== loadedDatabaseUrl ||
Boolean((pendingDbPassword || '').trim())
const databaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null) const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
@ -834,7 +855,11 @@ 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 sqlitePath = normalizeSqlitePath((data as any).sqlitePath)
setValue({ setValue({
databaseType,
sqlitePath,
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(),
@ -885,6 +910,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
trainingDetectorEpochs: trainingDetectorEpochs:
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs, (data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
}) })
setLoadedDatabaseType(databaseType)
setLoadedSqlitePath(sqlitePath)
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
}) })
.catch(() => { .catch(() => {
@ -1235,8 +1262,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setDbMaintenanceMsg(null) setDbMaintenanceMsg(null)
setDbMaintenanceErr(null) setDbMaintenanceErr(null)
if (!loadedDatabaseUrl) { if (!databaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') setDbMaintenanceErr('Bitte zuerst eine Datenbank-Konfiguration speichern.')
return return
} }
if (databaseSettingsDirty) { if (databaseSettingsDirty) {
@ -1284,8 +1311,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setDbMaintenanceMsg(null) setDbMaintenanceMsg(null)
setDbMaintenanceErr(null) setDbMaintenanceErr(null)
if (!loadedDatabaseUrl) { if (!databaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') setDbMaintenanceErr('Bitte zuerst eine Datenbank-Konfiguration speichern.')
return return
} }
if (databaseSettingsDirty) { if (databaseSettingsDirty) {
@ -1320,7 +1347,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
window.dispatchEvent(new Event('models-changed')) window.dispatchEvent(new Event('models-changed'))
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('models-db-changed', { new CustomEvent('models-db-changed', {
detail: { databaseUrl: loadedDatabaseUrl, restored: true }, detail: {
databaseType: loadedDatabaseType,
sqlitePath: loadedSqlitePath,
databaseUrl: loadedDatabaseUrl,
restored: true,
},
}) })
) )
} catch (e: any) { } catch (e: any) {
@ -1337,6 +1369,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const recordDir = value.recordDir.trim() const recordDir = value.recordDir.trim()
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 sqlitePath = normalizeSqlitePath((value as any).sqlitePath)
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())
@ -1344,6 +1378,14 @@ 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 (databaseType === 'sqlite' && !sqlitePath) {
setErr('Bitte SQLite-Datei angeben.')
return
}
if (databaseType === 'postgres' && !databaseUrl) {
setErr('Bitte Database URL angeben oder SQLite auswaehlen.')
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
@ -1395,6 +1437,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
databaseType,
sqlitePath,
databaseUrl, databaseUrl,
dbPassword: pendingDbPassword || undefined, dbPassword: pendingDbPassword || undefined,
recordDir, recordDir,
@ -1444,15 +1488,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}, 2500) }, 2500)
window.dispatchEvent(new CustomEvent('recorder-settings-updated')) window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
const databaseUrlChanged = const databaseChanged =
databaseUrl !== loadedDatabaseUrl || hadPendingDbPassword databaseType !== loadedDatabaseType ||
sqlitePath !== loadedSqlitePath ||
databaseUrl !== loadedDatabaseUrl ||
hadPendingDbPassword
if (databaseUrlChanged) { if (databaseChanged) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('models-db-changed', { new CustomEvent('models-db-changed', {
detail: { databaseUrl }, detail: { databaseType, sqlitePath, databaseUrl },
}) })
) )
setLoadedDatabaseType(databaseType)
setLoadedSqlitePath(sqlitePath)
setLoadedDatabaseUrl(databaseUrl) setLoadedDatabaseUrl(databaseUrl)
setPendingDbPassword('') setPendingDbPassword('')
} }
@ -2125,12 +2174,68 @@ 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">
Postgres Verbindung. Passwort wird verschlüsselt gespeichert. SQLite-Datei als portable Standard-Datenbank. Postgres bleibt optional verfuegbar.
</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"> <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">
Datenbank-Typ
</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">
{(['sqlite', 'postgres'] as const).map((type) => {
const active = currentDatabaseType === type
return (
<button
key={type}
type="button"
onClick={() => setValue((v) => ({ ...v, databaseType: type }))}
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(' ')}
>
{type === 'sqlite' ? 'SQLite' : 'Postgres'}
</button>
)
})}
</div>
</div>
</div>
{currentDatabaseType === 'sqlite' ? (
<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">
SQLite-Datei
</label>
<div className="sm:col-span-9">
<input
value={String((value as any).sqlitePath ?? DEFAULTS.sqlitePath ?? 'nsfwapp.sqlite')}
onChange={(e) => setValue((v) => ({ ...v, sqlitePath: e.target.value }))}
placeholder="nsfwapp.sqlite"
className="w-full 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 className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Relativ zur App oder absolut, z.B. /home/chris/nsfwapp/nsfwapp.sqlite.
</div>
</div>
</div>
) : null}
<div
className={[
currentDatabaseType === 'postgres' ? 'grid' : 'hidden',
'grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center',
].join(' ')}
>
<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
</label> </label>
@ -2158,7 +2263,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Backup / Restore Backup / Restore
</div> </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">
Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her. {currentDatabaseType === 'sqlite'
? 'Exportiert die aktive SQLite-Datei oder stellt sie aus einem SQLite-Backup wieder her.'
: 'Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her.'}
</div> </div>
{databaseSettingsDirty ? ( {databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200"> <div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
@ -2172,7 +2279,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
variant="secondary" variant="secondary"
color="emerald" color="emerald"
isLoading={dbBackupBusy} isLoading={dbBackupBusy}
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty} disabled={saving || dbMaintenanceBusy || !databaseConfigured || databaseSettingsDirty}
onClick={() => void downloadDatabaseBackup()} onClick={() => void downloadDatabaseBackup()}
> >
Backup herunterladen Backup herunterladen
@ -2182,7 +2289,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
variant="secondary" variant="secondary"
color="amber" color="amber"
isLoading={dbRestoreBusy} isLoading={dbRestoreBusy}
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty} disabled={saving || dbMaintenanceBusy || !databaseConfigured || databaseSettingsDirty}
onClick={chooseDatabaseRestoreFile} onClick={chooseDatabaseRestoreFile}
> >
Restore Restore
@ -2193,7 +2300,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<input <input
ref={dbRestoreInputRef} ref={dbRestoreInputRef}
type="file" type="file"
accept=".dump,.backup,.bin,application/octet-stream" accept={
loadedDatabaseType === 'sqlite'
? '.sqlite,.db,.sqlite3,application/vnd.sqlite3,application/octet-stream'
: '.dump,.backup,.bin,application/octet-stream'
}
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] ?? null const file = e.currentTarget.files?.[0] ?? null