removed sqlite
This commit is contained in:
parent
bee72d85e3
commit
3e32f9480a
@ -112,7 +112,7 @@ if [ ! -f "$runtime_bin" ] || ! cmp -s "$install_dir/nsfwapp" "$runtime_bin"; th
|
|||||||
mv "$tmp" "$runtime_bin"
|
mv "$tmp" "$runtime_bin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
setup_ml_python() {
|
configure_ml_python_setup() {
|
||||||
if [ "${NSFWAPP_SKIP_ML_SETUP:-0}" = "1" ]; then
|
if [ "${NSFWAPP_SKIP_ML_SETUP:-0}" = "1" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@ -125,45 +125,15 @@ setup_ml_python() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
venv_dir="${NSFWAPP_ML_VENV:-$app_dir/ml-venv}"
|
NSFWAPP_ML_SETUP=1
|
||||||
venv_python="$venv_dir/bin/python"
|
NSFWAPP_ML_REQUIREMENTS="$requirements_file"
|
||||||
requirements_marker="$venv_dir/.requirements-ml.txt"
|
NSFWAPP_ML_VENV="${NSFWAPP_ML_VENV:-$app_dir/ml-venv}"
|
||||||
setup_log="$app_dir/ml-setup.log"
|
NSFWAPP_ML_SETUP_LOG="${NSFWAPP_ML_SETUP_LOG:-$app_dir/ml-setup.log}"
|
||||||
|
export NSFWAPP_ML_SETUP NSFWAPP_ML_REQUIREMENTS NSFWAPP_ML_VENV NSFWAPP_ML_SETUP_LOG
|
||||||
if [ ! -x "$venv_python" ]; then
|
|
||||||
echo "Erstelle Python-ML-Umgebung: $venv_dir" >&2
|
|
||||||
if ! python3 -m venv "$venv_dir" >>"$setup_log" 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$requirements_marker" ] || ! cmp -s "$requirements_file" "$requirements_marker"; then
|
|
||||||
echo "Installiere Python-ML-Pakete. Details: $setup_log" >&2
|
|
||||||
if command -v notify-send >/dev/null 2>&1; then
|
|
||||||
notify-send "NSFW App" "ML-Python-Pakete werden installiert. Das kann beim ersten Start dauern." || true
|
|
||||||
fi
|
|
||||||
if ! "$venv_python" -m pip install --upgrade pip >>"$setup_log" 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! "$venv_python" -m pip install -r "$requirements_file" >>"$setup_log" 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! cp "$requirements_file" "$requirements_marker"; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
AI_SERVER_PYTHON="$venv_python"
|
|
||||||
export AI_SERVER_PYTHON
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if ! setup_ml_python; then
|
configure_ml_python_setup
|
||||||
echo "Warnung: ML-Python-Umgebung konnte nicht vorbereitet werden. Details: $app_dir/ml-setup.log" >&2
|
|
||||||
if command -v notify-send >/dev/null 2>&1; then
|
|
||||||
notify-send "NSFW App" "ML-Python-Umgebung konnte nicht vorbereitet werden. Siehe ml-setup.log." || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$app_dir"
|
cd "$app_dir"
|
||||||
exec "$runtime_bin" "$@"
|
exec "$runtime_bin" "$@"
|
||||||
@ -313,11 +283,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,
|
||||||
nsfwapp.sqlite, generated/, record/ und record/done im User-Bereich.
|
recorder_settings.json, PostgreSQL-Datenordner, 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.
|
||||||
SQLite ist die Standard-Datenbank. pg_dump/pg_restore werden nur fuer Postgres-Backup und Restore benoetigt.
|
PostgreSQL ist die Datenbank. Fuer lokale Datenordner werden initdb/pg_ctl und fuer Migration/Backup pg_dump/pg_restore benoetigt.
|
||||||
Der Launcher erstellt beim ersten Start automatisch ${NSFWAPP_HOME:-$HOME/nsfwapp}/ml-venv
|
Der Launcher erstellt beim ersten Start automatisch ${NSFWAPP_HOME:-$HOME/nsfwapp}/ml-venv
|
||||||
und installiert die mitgelieferte requirements-ml.txt. Details stehen in ml-setup.log.
|
und installiert die mitgelieferte requirements-ml.txt. Details stehen in ml-setup.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.
|
||||||
@ -330,7 +300,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
|
Depends: ca-certificates, ffmpeg, python3, python3-venv, xdg-utils, libnotify-bin, procps, postgresql
|
||||||
Description: $DESCRIPTION_SHORT
|
Description: $DESCRIPTION_SHORT
|
||||||
$DESCRIPTION_LONG
|
$DESCRIPTION_LONG
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -3,7 +3,6 @@ module nsfwapp
|
|||||||
go 1.25.3
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0
|
|
||||||
github.com/getlantern/systray v1.2.2
|
github.com/getlantern/systray v1.2.2
|
||||||
github.com/go-webauthn/webauthn v0.17.2
|
github.com/go-webauthn/webauthn v0.17.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@ -14,12 +13,10 @@ 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
|
||||||
@ -37,12 +34,9 @@ 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
|
||||||
@ -52,14 +46,10 @@ 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 (
|
||||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
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
|
||||||
|
|||||||
@ -1,16 +1,10 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
|
||||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
|
||||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
||||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
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=
|
||||||
@ -64,10 +58,6 @@ 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=
|
||||||
@ -80,8 +70,6 @@ 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=
|
||||||
@ -106,95 +94,32 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
||||||
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
|
||||||
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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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/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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
|
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
|
||||||
@ -203,11 +128,3 @@ 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=
|
|
||||||
|
|||||||
360
backend/ml_setup.go
Normal file
360
backend/ml_setup.go
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mlPythonSetupStatus struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
LogFile string `json:"logFile,omitempty"`
|
||||||
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
|
FinishedAt string `json:"finishedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mlPythonSetupConfig struct {
|
||||||
|
enabled bool
|
||||||
|
requirementsPath string
|
||||||
|
venvDir string
|
||||||
|
venvPython string
|
||||||
|
requirementsMark string
|
||||||
|
logPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mlSetupMu sync.RWMutex
|
||||||
|
mlSetupRunMu sync.Mutex
|
||||||
|
mlSetupStatus mlPythonSetupStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMLPythonSetupStatus() {
|
||||||
|
cfg := mlPythonSetupConfigFromEnv()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
|
||||||
|
mlSetupMu.Lock()
|
||||||
|
defer mlSetupMu.Unlock()
|
||||||
|
|
||||||
|
if cfg.enabled {
|
||||||
|
mlSetupStatus = mlPythonSetupStatus{
|
||||||
|
Required: true,
|
||||||
|
Running: false,
|
||||||
|
Done: false,
|
||||||
|
OK: false,
|
||||||
|
Progress: 0,
|
||||||
|
Phase: "pending",
|
||||||
|
Message: "ML-Python-Umgebung wartet auf Start.",
|
||||||
|
LogFile: filepath.Base(cfg.logPath),
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mlSetupStatus = mlPythonSetupStatus{
|
||||||
|
Required: false,
|
||||||
|
Running: false,
|
||||||
|
Done: true,
|
||||||
|
OK: true,
|
||||||
|
Progress: 100,
|
||||||
|
Phase: "skipped",
|
||||||
|
Message: "ML-Python-Setup nicht erforderlich.",
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlPythonSetupConfigFromEnv() mlPythonSetupConfig {
|
||||||
|
if strings.TrimSpace(os.Getenv("NSFWAPP_SKIP_ML_SETUP")) == "1" {
|
||||||
|
return mlPythonSetupConfig{}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON")) != "" {
|
||||||
|
return mlPythonSetupConfig{}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP")) != "1" {
|
||||||
|
return mlPythonSetupConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := strings.TrimSpace(os.Getenv("NSFWAPP_HOME"))
|
||||||
|
if appDir == "" {
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
appDir = wd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if appDir == "" {
|
||||||
|
appDir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
requirementsPath := strings.TrimSpace(os.Getenv("NSFWAPP_ML_REQUIREMENTS"))
|
||||||
|
if requirementsPath == "" {
|
||||||
|
requirementsPath = filepath.Join(appDir, "requirements-ml.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV"))
|
||||||
|
if venvDir == "" {
|
||||||
|
venvDir = filepath.Join(appDir, "ml-venv")
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP_LOG"))
|
||||||
|
if logPath == "" {
|
||||||
|
logPath = filepath.Join(appDir, "ml-setup.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mlPythonSetupConfig{
|
||||||
|
enabled: true,
|
||||||
|
requirementsPath: filepath.Clean(requirementsPath),
|
||||||
|
venvDir: filepath.Clean(venvDir),
|
||||||
|
venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"),
|
||||||
|
requirementsMark: filepath.Join(filepath.Clean(venvDir), ".requirements-ml.txt"),
|
||||||
|
logPath: filepath.Clean(logPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlPythonSetupStatusSnapshot() mlPythonSetupStatus {
|
||||||
|
mlSetupMu.RLock()
|
||||||
|
defer mlSetupMu.RUnlock()
|
||||||
|
return mlSetupStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMLPythonSetupStatus(update func(*mlPythonSetupStatus)) {
|
||||||
|
mlSetupMu.Lock()
|
||||||
|
defer mlSetupMu.Unlock()
|
||||||
|
|
||||||
|
update(&mlSetupStatus)
|
||||||
|
mlSetupStatus.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mlPythonSetupStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, mlPythonSetupStatusSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMLPythonSetup(ctx context.Context) error {
|
||||||
|
cfg := mlPythonSetupConfigFromEnv()
|
||||||
|
if !cfg.enabled {
|
||||||
|
initMLPythonSetupStatus()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mlSetupRunMu.Lock()
|
||||||
|
defer mlSetupRunMu.Unlock()
|
||||||
|
|
||||||
|
if strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON")) != "" {
|
||||||
|
initMLPythonSetupStatus()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Required = true
|
||||||
|
s.Running = true
|
||||||
|
s.Done = false
|
||||||
|
s.OK = false
|
||||||
|
s.Progress = 1
|
||||||
|
s.Phase = "prepare"
|
||||||
|
s.Message = "ML-Python-Umgebung wird vorbereitet."
|
||||||
|
s.Error = ""
|
||||||
|
s.LogFile = filepath.Base(cfg.logPath)
|
||||||
|
s.StartedAt = startedAt
|
||||||
|
s.FinishedAt = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
reqBytes, err := os.ReadFile(cfg.requirementsPath)
|
||||||
|
if err != nil {
|
||||||
|
return finishMLPythonSetupError("Requirements konnten nicht gelesen werden.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.venvDir, 0755); err != nil {
|
||||||
|
return finishMLPythonSetupError("ML-Venv-Ordner konnte nicht erstellt werden.", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.logPath), 0755); err != nil {
|
||||||
|
return finishMLPythonSetupError("ML-Setup-Logordner konnte nicht erstellt werden.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if current, err := os.ReadFile(cfg.requirementsMark); err == nil && string(current) == string(reqBytes) {
|
||||||
|
if fi, statErr := os.Stat(cfg.venvPython); statErr == nil && fi != nil && !fi.IsDir() {
|
||||||
|
_ = os.Setenv("AI_SERVER_PYTHON", cfg.venvPython)
|
||||||
|
finishMLPythonSetupSuccess("ML-Python-Pakete sind bereits installiert.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fi, err := os.Stat(cfg.venvPython); err != nil || fi == nil || fi.IsDir() {
|
||||||
|
python3, lookErr := exec.LookPath("python3")
|
||||||
|
if lookErr != nil {
|
||||||
|
return finishMLPythonSetupError("python3 wurde nicht gefunden.", lookErr)
|
||||||
|
}
|
||||||
|
if err := runMLPythonSetupCommand(ctx, cfg, "venv", "Python-Umgebung wird erstellt.", 5, 20, python3, "-m", "venv", cfg.venvDir); err != nil {
|
||||||
|
return finishMLPythonSetupError("Python-Umgebung konnte nicht erstellt werden.", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runMLPythonSetupCommand(ctx, cfg, "pip", "pip wird aktualisiert.", 22, 38, cfg.venvPython, "-m", "pip", "install", "--upgrade", "pip"); err != nil {
|
||||||
|
return finishMLPythonSetupError("pip konnte nicht aktualisiert werden.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runMLPythonSetupCommand(ctx, cfg, "requirements", "ML-Python-Pakete werden installiert.", 40, 94, cfg.venvPython, "-m", "pip", "install", "-r", cfg.requirementsPath); err != nil {
|
||||||
|
return finishMLPythonSetupError("ML-Python-Pakete konnten nicht installiert werden.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := atomicWriteFile(cfg.requirementsMark, reqBytes); err != nil {
|
||||||
|
return finishMLPythonSetupError("ML-Requirements-Marker konnte nicht geschrieben werden.", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Setenv("AI_SERVER_PYTHON", cfg.venvPython)
|
||||||
|
finishMLPythonSetupSuccess("ML-Python-Pakete wurden installiert.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase, message string, startProgress, endProgress int, name string, args ...string) error {
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Running = true
|
||||||
|
s.Progress = startProgress
|
||||||
|
s.Phase = phase
|
||||||
|
s.Message = message
|
||||||
|
})
|
||||||
|
|
||||||
|
logFile, err := os.OpenFile(cfg.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
|
||||||
|
_, _ = logFile.WriteString("\n[" + time.Now().Format(time.RFC3339) + "] " + name + " " + strings.Join(args, " ") + "\n")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Dir = filepath.Dir(cfg.logPath)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string, 64)
|
||||||
|
var scanWG sync.WaitGroup
|
||||||
|
scan := func(r io.Reader) {
|
||||||
|
defer scanWG.Done()
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
_, _ = logFile.WriteString(line + "\n")
|
||||||
|
select {
|
||||||
|
case lines <- line:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanWG.Add(2)
|
||||||
|
go scan(stdout)
|
||||||
|
go scan(stderr)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
scanWG.Wait()
|
||||||
|
done <- cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
lineCount := 0
|
||||||
|
progressRange := endProgress - startProgress
|
||||||
|
if progressRange < 1 {
|
||||||
|
progressRange = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case line := <-lines:
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineCount++
|
||||||
|
progress := startProgress + minInt(progressRange-1, lineCount)
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Progress = progress
|
||||||
|
s.Phase = phase
|
||||||
|
s.Message = line
|
||||||
|
})
|
||||||
|
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Progress = endProgress
|
||||||
|
s.Phase = phase
|
||||||
|
s.Message = message
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishMLPythonSetupSuccess(message string) {
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Required = true
|
||||||
|
s.Running = false
|
||||||
|
s.Done = true
|
||||||
|
s.OK = true
|
||||||
|
s.Progress = 100
|
||||||
|
s.Phase = "done"
|
||||||
|
s.Message = message
|
||||||
|
s.Error = ""
|
||||||
|
s.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishMLPythonSetupError(message string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New(message)
|
||||||
|
}
|
||||||
|
updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) {
|
||||||
|
s.Required = true
|
||||||
|
s.Running = false
|
||||||
|
s.Done = true
|
||||||
|
s.OK = false
|
||||||
|
s.Phase = "error"
|
||||||
|
s.Message = message
|
||||||
|
s.Error = err.Error()
|
||||||
|
s.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
})
|
||||||
|
return appErrorf("%s: %w", message, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@ -1,37 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
databaseTypeSQLite = "sqlite"
|
|
||||||
databaseTypePostgres = "postgres"
|
databaseTypePostgres = "postgres"
|
||||||
defaultSQLitePath = "nsfwapp.sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelStoreConfig struct {
|
type ModelStoreConfig struct {
|
||||||
Type string
|
Type string
|
||||||
DSN string
|
DSN string
|
||||||
SQLitePath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeDatabaseType(raw string) string {
|
func normalizeDatabaseType(raw string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
_ = raw
|
||||||
case databaseTypePostgres:
|
|
||||||
return 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) {
|
func buildModelStoreConfigFromSettings() (ModelStoreConfig, error) {
|
||||||
@ -39,22 +19,9 @@ func buildModelStoreConfigFromSettings() (ModelStoreConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildModelStoreConfigFromRecorderSettings(s RecorderSettings) (ModelStoreConfig, error) {
|
func buildModelStoreConfigFromRecorderSettings(s RecorderSettings) (ModelStoreConfig, error) {
|
||||||
dbType := normalizeDatabaseType(s.DatabaseType)
|
|
||||||
if dbType == databaseTypePostgres {
|
|
||||||
dsn, err := buildPostgresDSNFromRecorderSettings(s)
|
dsn, err := buildPostgresDSNFromRecorderSettings(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ModelStoreConfig{}, err
|
return ModelStoreConfig{}, err
|
||||||
}
|
}
|
||||||
return ModelStoreConfig{Type: databaseTypePostgres, DSN: dsn}, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoredModel struct {
|
type StoredModel struct {
|
||||||
@ -69,15 +68,6 @@ type ModelsMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sqliteMaxOpenConns = 4
|
|
||||||
sqliteMaxIdleConns = 4
|
|
||||||
sqliteBusyTimeoutMS = 15000
|
|
||||||
sqliteCacheSizeKiB = 65536
|
|
||||||
sqliteMMapSizeBytes = 268435456
|
|
||||||
sqliteWALAutoCheckpt = 1000
|
|
||||||
sqliteAnalyzeLimit = 1000
|
|
||||||
|
|
||||||
modelProfileImageDirName = "model-profile-images"
|
|
||||||
modelStatusFlushDelay = 1500 * time.Millisecond
|
modelStatusFlushDelay = 1500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -269,42 +259,9 @@ func parseRFC3339Nano(s string) sql.NullTime {
|
|||||||
func NewModelStore(config ModelStoreConfig) *ModelStore {
|
func NewModelStore(config ModelStoreConfig) *ModelStore {
|
||||||
config.Type = normalizeDatabaseType(config.Type)
|
config.Type = normalizeDatabaseType(config.Type)
|
||||||
config.DSN = strings.TrimSpace(config.DSN)
|
config.DSN = strings.TrimSpace(config.DSN)
|
||||||
config.SQLitePath = strings.TrimSpace(config.SQLitePath)
|
|
||||||
return &ModelStore{config: config}
|
return &ModelStore{config: config}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sqliteTuningPragmas() []string {
|
|
||||||
return []string{
|
|
||||||
fmt.Sprintf("busy_timeout=%d", sqliteBusyTimeoutMS),
|
|
||||||
"foreign_keys=ON",
|
|
||||||
"journal_mode=WAL",
|
|
||||||
"synchronous=NORMAL",
|
|
||||||
"temp_store=MEMORY",
|
|
||||||
fmt.Sprintf("cache_size=-%d", sqliteCacheSizeKiB),
|
|
||||||
fmt.Sprintf("mmap_size=%d", sqliteMMapSizeBytes),
|
|
||||||
fmt.Sprintf("wal_autocheckpoint=%d", sqliteWALAutoCheckpt),
|
|
||||||
fmt.Sprintf("analysis_limit=%d", sqliteAnalyzeLimit),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqliteTunedDSN(path string) string {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
q := url.Values{}
|
|
||||||
for _, pragma := range sqliteTuningPragmas() {
|
|
||||||
q.Add("_pragma", pragma)
|
|
||||||
}
|
|
||||||
|
|
||||||
sep := "?"
|
|
||||||
if strings.Contains(path, "?") {
|
|
||||||
sep = "&"
|
|
||||||
}
|
|
||||||
return path + sep + q.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ModelStore) Load() error { return s.ensureInit() }
|
func (s *ModelStore) Load() error { return s.ensureInit() }
|
||||||
|
|
||||||
func (s *ModelStore) Close() error {
|
func (s *ModelStore) Close() error {
|
||||||
@ -336,25 +293,6 @@ func (s *ModelStore) init() error {
|
|||||||
|
|
||||||
driverName := "pgx"
|
driverName := "pgx"
|
||||||
dsn := strings.TrimSpace(config.DSN)
|
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 = sqliteTunedDSN(sqlitePath)
|
|
||||||
config.SQLitePath = sqlitePath
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(dsn) == "" {
|
if strings.TrimSpace(dsn) == "" {
|
||||||
return errors.New("db dsn fehlt")
|
return errors.New("db dsn fehlt")
|
||||||
}
|
}
|
||||||
@ -364,13 +302,8 @@ func (s *ModelStore) init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Type == databaseTypeSQLite {
|
|
||||||
db.SetMaxOpenConns(sqliteMaxOpenConns)
|
|
||||||
db.SetMaxIdleConns(sqliteMaxIdleConns)
|
|
||||||
} else {
|
|
||||||
db.SetMaxOpenConns(10)
|
db.SetMaxOpenConns(10)
|
||||||
db.SetMaxIdleConns(10)
|
db.SetMaxIdleConns(10)
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
@ -380,17 +313,10 @@ func (s *ModelStore) init() error {
|
|||||||
s.db = db
|
s.db = db
|
||||||
s.config = config
|
s.config = config
|
||||||
|
|
||||||
if config.Type == databaseTypeSQLite {
|
|
||||||
if err := s.ensureSQLiteSchema(); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := s.ensurePostgresSchema(); err != nil {
|
if err := s.ensurePostgresSchema(); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -399,147 +325,18 @@ 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 {
|
func (s *ModelStore) blobLengthExpr() string {
|
||||||
if s.isSQLite() {
|
|
||||||
return "length(profile_image_blob)"
|
|
||||||
}
|
|
||||||
return "octet_length(profile_image_blob)"
|
return "octet_length(profile_image_blob)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) profileImagePresenceExpr() string {
|
func (s *ModelStore) profileImagePresenceExpr() string {
|
||||||
if s.isSQLite() {
|
|
||||||
return "CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' OR profile_image_blob IS NOT NULL THEN 1 ELSE 0 END"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' OR (profile_image_blob IS NOT NULL AND %s > 0) THEN 1 ELSE 0 END", s.blobLengthExpr())
|
return fmt.Sprintf("CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' OR (profile_image_blob IS NOT NULL AND %s > 0) THEN 1 ELSE 0 END", s.blobLengthExpr())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) emptyTextExpr() string {
|
func (s *ModelStore) emptyTextExpr() string {
|
||||||
if s.isSQLite() {
|
|
||||||
return "''"
|
|
||||||
}
|
|
||||||
return "''::text"
|
return "''::text"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) ensureSQLiteModelColumns() error {
|
|
||||||
rows, err := s.db.Query(`PRAGMA table_info(models);`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
existing := make(map[string]struct{}, 64)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
cid int
|
|
||||||
name string
|
|
||||||
colType string
|
|
||||||
notNull int
|
|
||||||
defaultV sql.NullString
|
|
||||||
primaryID int
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultV, &primaryID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
columns := []struct {
|
|
||||||
name string
|
|
||||||
ddl string
|
|
||||||
}{
|
|
||||||
{name: "cb_online_last_error", ddl: "cb_online_last_error TEXT DEFAULT ''"},
|
|
||||||
{name: "profile_image_path", ddl: "profile_image_path TEXT DEFAULT ''"},
|
|
||||||
{name: "profile_image_hash", ddl: "profile_image_hash TEXT DEFAULT ''"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, column := range columns {
|
|
||||||
if _, ok := existing[strings.ToLower(column.name)]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := s.db.Exec(`ALTER TABLE models ADD COLUMN ` + column.ddl + `;`); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ModelStore) ensureSQLiteSchema() error {
|
|
||||||
stmts := make([]string, 0, len(sqliteTuningPragmas())+6)
|
|
||||||
for _, pragma := range sqliteTuningPragmas() {
|
|
||||||
stmts = append(stmts, "PRAGMA "+pragma+";")
|
|
||||||
}
|
|
||||||
|
|
||||||
stmts = append(stmts,
|
|
||||||
`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_path TEXT DEFAULT '',
|
|
||||||
profile_image_hash 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_watching_updated ON models(watching, updated_at DESC);`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_models_favorite_updated ON models(favorite, updated_at DESC);`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_models_liked_updated ON models(liked, updated_at DESC);`,
|
|
||||||
`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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.ensureSQLiteModelColumns(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.migrateSQLiteProfileImageBlobsToFiles(); err != nil {
|
|
||||||
appLogln("models sqlite profile image blob migration skipped:", err)
|
|
||||||
}
|
|
||||||
if _, err := s.db.Exec(`PRAGMA optimize;`); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ModelStore) ensurePostgresSchema() error {
|
func (s *ModelStore) ensurePostgresSchema() error {
|
||||||
stmts := []string{
|
stmts := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS models (
|
`CREATE TABLE IF NOT EXISTS models (
|
||||||
@ -1044,38 +841,11 @@ WHERE lower(trim(host)) = 'chaturbate.com'
|
|||||||
|
|
||||||
// --- Profile image cache ---
|
// --- Profile image cache ---
|
||||||
|
|
||||||
func profileImageExtForMime(mime string) string {
|
|
||||||
m := strings.ToLower(strings.TrimSpace(mime))
|
|
||||||
if i := strings.Index(m, ";"); i >= 0 {
|
|
||||||
m = strings.TrimSpace(m[:i])
|
|
||||||
}
|
|
||||||
switch m {
|
|
||||||
case "image/png":
|
|
||||||
return ".png"
|
|
||||||
case "image/webp":
|
|
||||||
return ".webp"
|
|
||||||
case "image/gif":
|
|
||||||
return ".gif"
|
|
||||||
case "image/jpeg", "image/jpg":
|
|
||||||
return ".jpg"
|
|
||||||
default:
|
|
||||||
return ".img"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func profileImageHash(data []byte) string {
|
func profileImageHash(data []byte) string {
|
||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
return fmt.Sprintf("%x", sum[:])
|
return fmt.Sprintf("%x", sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) profileImageRelPath(hash string, mime string) string {
|
|
||||||
hash = strings.ToLower(strings.TrimSpace(hash))
|
|
||||||
if len(hash) < 2 {
|
|
||||||
hash = "unknown"
|
|
||||||
}
|
|
||||||
return filepath.ToSlash(filepath.Join(modelProfileImageDirName, hash[:2], hash+profileImageExtForMime(mime)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ModelStore) resolveProfileImagePath(storedPath string) (string, error) {
|
func (s *ModelStore) resolveProfileImagePath(storedPath string) (string, error) {
|
||||||
storedPath = strings.TrimSpace(storedPath)
|
storedPath = strings.TrimSpace(storedPath)
|
||||||
if storedPath == "" {
|
if storedPath == "" {
|
||||||
@ -1090,109 +860,15 @@ func (s *ModelStore) resolveProfileImagePath(storedPath string) (string, error)
|
|||||||
return "", fmt.Errorf("ungueltiger profile image path: %s", storedPath)
|
return "", fmt.Errorf("ungueltiger profile image path: %s", storedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlitePath := strings.TrimSpace(s.config.SQLitePath)
|
slashPath := filepath.ToSlash(local)
|
||||||
if sqlitePath == "" {
|
if slashPath == "generated" || strings.HasPrefix(slashPath, "generated/") {
|
||||||
sqlitePath = strings.TrimSpace(s.config.DSN)
|
return resolvePathRelativeToApp(slashPath)
|
||||||
}
|
|
||||||
if sqlitePath == "" {
|
|
||||||
return "", errors.New("sqlite path fehlt")
|
|
||||||
}
|
|
||||||
return filepath.Join(filepath.Dir(sqlitePath), local), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) storeSQLiteProfileImageFile(data []byte, mime string) (relPath string, hash string, err error) {
|
return "", fmt.Errorf("ungueltiger profile image path: %s", storedPath)
|
||||||
if len(data) == 0 {
|
|
||||||
return "", "", errors.New("image data fehlt")
|
|
||||||
}
|
|
||||||
hash = profileImageHash(data)
|
|
||||||
relPath = s.profileImageRelPath(hash, mime)
|
|
||||||
absPath, err := s.resolveProfileImagePath(relPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if err := atomicWriteFile(absPath, data); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return relPath, hash, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) migrateSQLiteProfileImageBlobsToFiles() error {
|
// SetProfileImage speichert Bild-URL, MIME und Bilddaten in Postgres.
|
||||||
if !s.isSQLite() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT id, COALESCE(profile_image_mime,''), profile_image_blob
|
|
||||||
FROM models
|
|
||||||
WHERE profile_image_blob IS NOT NULL
|
|
||||||
AND (profile_image_path IS NULL OR trim(profile_image_path) = '');
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type migratedProfileImage struct {
|
|
||||||
id string
|
|
||||||
path string
|
|
||||||
hash string
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated := make([]migratedProfileImage, 0, 32)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
id string
|
|
||||||
mime string
|
|
||||||
blob []byte
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&id, &mime, &blob); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(id) == "" || len(blob) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(mime) == "" {
|
|
||||||
mime = http.DetectContentType(blob)
|
|
||||||
}
|
|
||||||
relPath, hash, err := s.storeSQLiteProfileImageFile(blob, mime)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
migrated = append(migrated, migratedProfileImage{id: id, path: relPath, hash: hash})
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(migrated) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
stmt, err := tx.Prepare(`
|
|
||||||
UPDATE models
|
|
||||||
SET profile_image_path=$1, profile_image_hash=$2, profile_image_blob=NULL
|
|
||||||
WHERE id=$3;
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
for _, item := range migrated {
|
|
||||||
if _, err := stmt.Exec(item.path, item.hash, item.id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProfileImage speichert Bild-URL + MIME. SQLite legt die Bilddaten als Datei neben der DB ab.
|
|
||||||
// Speichert nur fuer bereits bestehende Models.
|
// Speichert nur fuer bereits bestehende Models.
|
||||||
func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, data []byte, updatedAt string) error {
|
func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, data []byte, updatedAt string) error {
|
||||||
if err := s.ensureInit(); err != nil {
|
if err := s.ensureInit(); err != nil {
|
||||||
@ -1236,14 +912,6 @@ LIMIT 1;
|
|||||||
}
|
}
|
||||||
|
|
||||||
hash := profileImageHash(data)
|
hash := profileImageHash(data)
|
||||||
imagePath := ""
|
|
||||||
if s.isSQLite() {
|
|
||||||
var err error
|
|
||||||
imagePath, hash, err = s.storeSQLiteProfileImageFile(data, mime)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nt := parseRFC3339Nano(updatedAt)
|
nt := parseRFC3339Nano(updatedAt)
|
||||||
if !nt.Valid {
|
if !nt.Valid {
|
||||||
@ -1254,22 +922,12 @@ LIMIT 1;
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
var res sql.Result
|
res, err := s.db.Exec(`
|
||||||
if s.isSQLite() {
|
|
||||||
res, err = s.db.Exec(`
|
|
||||||
UPDATE models
|
|
||||||
SET profile_image_url=$1, profile_image_mime=$2, profile_image_path=$3, profile_image_hash=$4, profile_image_blob=NULL, profile_image_updated_at=$5, updated_at=$6
|
|
||||||
WHERE lower(trim(host)) = lower(trim($7))
|
|
||||||
AND lower(trim(model_key)) = lower(trim($8));
|
|
||||||
`, src, mime, imagePath, hash, nullableTimeArg(nt), now, host, key)
|
|
||||||
} else {
|
|
||||||
res, err = s.db.Exec(`
|
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET profile_image_url=$1, profile_image_mime=$2, profile_image_path='', profile_image_hash=$3, profile_image_blob=$4, profile_image_updated_at=$5, updated_at=$6
|
SET profile_image_url=$1, profile_image_mime=$2, profile_image_path='', profile_image_hash=$3, profile_image_blob=$4, profile_image_updated_at=$5, updated_at=$6
|
||||||
WHERE lower(trim(host)) = lower(trim($7))
|
WHERE lower(trim(host)) = lower(trim($7))
|
||||||
AND lower(trim(model_key)) = lower(trim($8));
|
AND lower(trim(model_key)) = lower(trim($8));
|
||||||
`, src, mime, hash, data, nullableTimeArg(nt), now, host, key)
|
`, src, mime, hash, data, nullableTimeArg(nt), now, host, key)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -1381,12 +1039,6 @@ LIMIT 1;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isSQLite() {
|
|
||||||
if relPath, hash, werr := s.storeSQLiteProfileImageFile(blob, m); werr == nil {
|
|
||||||
_, _ = s.db.Exec(`UPDATE models SET profile_image_path=$1, profile_image_hash=$2, profile_image_blob=NULL WHERE id=$3;`, relPath, hash, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, blob, true, nil
|
return m, blob, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1656,10 +1308,6 @@ 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,
|
_, err = tx.Exec(updateSQL,
|
||||||
newInput, host, newPath,
|
newInput, host, newPath,
|
||||||
it.tags, it.tags,
|
it.tags, it.tags,
|
||||||
|
|||||||
@ -1,235 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
if err := store.flushPendingStatusUpdates(); err != nil {
|
|
||||||
t.Fatalf("flush status updates: %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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelStoreSQLiteStoresProfileImagesAsFiles(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 := store.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil {
|
|
||||||
t.Fatalf("ensure model: %v", err)
|
|
||||||
}
|
|
||||||
data := []byte{0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00}
|
|
||||||
if err := store.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", data, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
||||||
t.Fatalf("set profile image: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var relPath, hash string
|
|
||||||
var blob []byte
|
|
||||||
if err := store.db.QueryRow(`SELECT profile_image_path, profile_image_hash, profile_image_blob FROM models WHERE id=$1;`, "chaturbate.com:alice").Scan(&relPath, &hash, &blob); err != nil {
|
|
||||||
t.Fatalf("query profile image fields: %v", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(relPath) == "" {
|
|
||||||
t.Fatal("expected profile image path")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(hash) == "" {
|
|
||||||
t.Fatal("expected profile image hash")
|
|
||||||
}
|
|
||||||
if len(blob) != 0 {
|
|
||||||
t.Fatalf("expected sqlite blob cleared, got %d bytes", len(blob))
|
|
||||||
}
|
|
||||||
|
|
||||||
absPath, err := store.resolveProfileImagePath(relPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve profile image path: %v", err)
|
|
||||||
}
|
|
||||||
if b, err := os.ReadFile(absPath); err != nil {
|
|
||||||
t.Fatalf("read profile image file: %v", err)
|
|
||||||
} else if string(b) != string(data) {
|
|
||||||
t.Fatalf("unexpected profile image data: %v", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
mime, got, ok, err := store.GetProfileImageByID("chaturbate.com:alice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get profile image: %v", err)
|
|
||||||
}
|
|
||||||
if !ok || mime != "image/jpeg" || string(got) != string(data) {
|
|
||||||
t.Fatalf("unexpected profile image response ok=%v mime=%q data=%v", ok, mime, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
sourcePath := filepath.Join(tmp, "source.sqlite")
|
|
||||||
targetPath := filepath.Join(tmp, "target.sqlite")
|
|
||||||
|
|
||||||
sourceConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: sourcePath, SQLitePath: sourcePath}
|
|
||||||
targetConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: targetPath, SQLitePath: targetPath}
|
|
||||||
|
|
||||||
source := NewModelStore(sourceConfig)
|
|
||||||
defer source.Close()
|
|
||||||
if err := source.Load(); err != nil {
|
|
||||||
t.Fatalf("load source: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := source.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil {
|
|
||||||
t.Fatalf("ensure source model: %v", err)
|
|
||||||
}
|
|
||||||
if err := source.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", []byte{1, 2, 3, 4}, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
||||||
t.Fatalf("set profile image: %v", err)
|
|
||||||
}
|
|
||||||
if err := source.SetBioContext("chaturbate.com", "alice", `{"ok":true}`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
||||||
t.Fatalf("set bio context: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := NewModelStore(targetConfig)
|
|
||||||
if err := target.Load(); err != nil {
|
|
||||||
t.Fatalf("load target: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := target.EnsureByHostModelKey("chaturbate.com", "stale"); err != nil {
|
|
||||||
t.Fatalf("ensure stale target model: %v", err)
|
|
||||||
}
|
|
||||||
_ = target.Close()
|
|
||||||
_ = source.Close()
|
|
||||||
|
|
||||||
count, err := migrateModelStoreData(sourceConfig, targetConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migrate sqlite to sqlite: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("expected 1 migrated row, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated := NewModelStore(targetConfig)
|
|
||||||
defer migrated.Close()
|
|
||||||
if err := migrated.Load(); err != nil {
|
|
||||||
t.Fatalf("load migrated target: %v", err)
|
|
||||||
}
|
|
||||||
models := migrated.List()
|
|
||||||
if len(models) != 1 {
|
|
||||||
t.Fatalf("expected target replacement with 1 row, got %d", len(models))
|
|
||||||
}
|
|
||||||
if models[0].ModelKey != "alice" {
|
|
||||||
t.Fatalf("unexpected migrated model: %q", models[0].ModelKey)
|
|
||||||
}
|
|
||||||
if models[0].ProfileImageCached == "" {
|
|
||||||
t.Fatal("expected migrated profile image blob marker")
|
|
||||||
}
|
|
||||||
bio, _, ok, err := migrated.GetBioContext("chaturbate.com", "alice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get migrated bio context: %v", err)
|
|
||||||
}
|
|
||||||
if !ok || bio != `{"ok":true}` {
|
|
||||||
t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveSQLiteDatabaseFilesMovesSidecars(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
sourcePath := filepath.Join(tmp, "old", "models.sqlite")
|
|
||||||
targetPath := filepath.Join(tmp, "new", "models.sqlite")
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(sourcePath), 0755); err != nil {
|
|
||||||
t.Fatalf("mkdir source: %v", err)
|
|
||||||
}
|
|
||||||
files := map[string]string{
|
|
||||||
sourcePath: "main",
|
|
||||||
sourcePath + "-wal": "wal",
|
|
||||||
sourcePath + "-shm": "shm",
|
|
||||||
sourcePath + "-journal": "journal",
|
|
||||||
}
|
|
||||||
for path, content := range files {
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
||||||
t.Fatalf("write %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceImageDir := filepath.Join(filepath.Dir(sourcePath), modelProfileImageDirName)
|
|
||||||
sourceImagePath := filepath.Join(sourceImageDir, "aa", "avatar.jpg")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(sourceImagePath), 0755); err != nil {
|
|
||||||
t.Fatalf("mkdir profile image dir: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(sourceImagePath, []byte("image"), 0644); err != nil {
|
|
||||||
t.Fatalf("write profile image: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := moveSQLiteDatabaseFiles(sourcePath, targetPath); err != nil {
|
|
||||||
t.Fatalf("move sqlite files: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for source, content := range files {
|
|
||||||
if _, err := os.Stat(source); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("expected source removed %s, err=%v", source, err)
|
|
||||||
}
|
|
||||||
target := strings.Replace(source, sourcePath, targetPath, 1)
|
|
||||||
b, err := os.ReadFile(target)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read target %s: %v", target, err)
|
|
||||||
}
|
|
||||||
if string(b) != content {
|
|
||||||
t.Fatalf("unexpected target content for %s: %q", target, string(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(sourceImagePath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("expected source profile image moved, err=%v", err)
|
|
||||||
}
|
|
||||||
targetImagePath := strings.Replace(sourceImagePath, filepath.Dir(sourcePath), filepath.Dir(targetPath), 1)
|
|
||||||
b, err := os.ReadFile(targetImagePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read target profile image: %v", err)
|
|
||||||
}
|
|
||||||
if string(b) != "image" {
|
|
||||||
t.Fatalf("unexpected target profile image content: %q", string(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
250
backend/postgres_local.go
Normal file
250
backend/postgres_local.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
postgresModeURL = "url"
|
||||||
|
postgresModeLocal = "local"
|
||||||
|
|
||||||
|
defaultPostgresDataDir = "postgres-data"
|
||||||
|
defaultPostgresPort = 55432
|
||||||
|
defaultPostgresDatabase = "nsfwapp"
|
||||||
|
defaultPostgresUser = "nsfwapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizePostgresMode(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case postgresModeLocal:
|
||||||
|
return postgresModeLocal
|
||||||
|
default:
|
||||||
|
return postgresModeURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePostgresDataDir(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
value = defaultPostgresDataDir
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.FromSlash(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := resolvePathRelativeToApp(s.PostgresDataDir)
|
||||||
|
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 ensureLocalPostgresCluster(ctx context.Context, dataDir string, port int, user, database string) error {
|
||||||
|
dataDir = filepath.Clean(strings.TrimSpace(dataDir))
|
||||||
|
if dataDir == "" {
|
||||||
|
return appErrorf("postgresDataDir ist leer")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dataDir), 0755); 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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "status")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return appErrorf("lokaler PostgreSQL-Server konnte nicht gestartet werden: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
mux.HandleFunc("/api/auth/passkey/login/options", authPasskeyLoginOptionsHandler(auth))
|
mux.HandleFunc("/api/auth/passkey/login/options", authPasskeyLoginOptionsHandler(auth))
|
||||||
mux.HandleFunc("/api/auth/passkey/login/verify", authPasskeyLoginVerifyHandler(auth))
|
mux.HandleFunc("/api/auth/passkey/login/verify", authPasskeyLoginVerifyHandler(auth))
|
||||||
mux.HandleFunc("/api/auth/passkey/delete", authPasskeyDeleteHandler(auth))
|
mux.HandleFunc("/api/auth/passkey/delete", authPasskeyDeleteHandler(auth))
|
||||||
|
mux.HandleFunc("/api/setup/ml/status", mlPythonSetupStatusHandler)
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// 2) Protected API Mux
|
// 2) Protected API Mux
|
||||||
@ -128,11 +129,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
appLogln("models DB config:", err)
|
appLogln("models DB config:", err)
|
||||||
}
|
}
|
||||||
if storeConfig.Type == databaseTypePostgres {
|
|
||||||
appLogln("Models DB (Postgres):", sanitizeDSNForLog(storeConfig.DSN))
|
appLogln("Models DB (Postgres):", sanitizeDSNForLog(storeConfig.DSN))
|
||||||
} else {
|
|
||||||
appLogln("Models DB (SQLite):", storeConfig.SQLitePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := NewModelStore(storeConfig)
|
store := NewModelStore(storeConfig)
|
||||||
if err := store.Load(); err != nil {
|
if err := store.Load(); err != nil {
|
||||||
@ -174,6 +171,10 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
func buildPostgresDSNFromSettings() (string, error) {
|
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)
|
||||||
|
if s.PostgresMode == postgresModeLocal {
|
||||||
|
return buildLocalPostgresDSN(s)
|
||||||
|
}
|
||||||
|
|
||||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
@ -228,6 +229,11 @@ func buildPostgresDSNFromSettings() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
|
func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) {
|
||||||
|
normalizePostgresSettings(&s)
|
||||||
|
if s.PostgresMode == postgresModeLocal {
|
||||||
|
return buildLocalPostgresDSN(s)
|
||||||
|
}
|
||||||
|
|
||||||
dbURL := strings.TrimSpace(s.DatabaseURL)
|
dbURL := strings.TrimSpace(s.DatabaseURL)
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
return "", appErrorf("databaseUrl ist leer")
|
return "", appErrorf("databaseUrl ist leer")
|
||||||
|
|||||||
@ -467,6 +467,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureMLPythonSetup(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
scriptDir, err := findAIServerScriptDir()
|
scriptDir, err := findAIServerScriptDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -973,6 +977,12 @@ func main() {
|
|||||||
|
|
||||||
appCtx, appCancel := context.WithCancel(context.Background())
|
appCtx, appCancel := context.WithCancel(context.Background())
|
||||||
defer appCancel()
|
defer appCancel()
|
||||||
|
initMLPythonSetupStatus()
|
||||||
|
|
||||||
|
aiServerMu.Lock()
|
||||||
|
aiServerCtx = appCtx
|
||||||
|
aiServerCurrent = nil
|
||||||
|
aiServerMu.Unlock()
|
||||||
|
|
||||||
listenAddr := ":" + appPort()
|
listenAddr := ":" + appPort()
|
||||||
|
|
||||||
@ -988,21 +998,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer appLn.Close()
|
defer appLn.Close()
|
||||||
|
|
||||||
aiProc, err := startAIServer(appCtx)
|
|
||||||
if err != nil {
|
|
||||||
appLogln("⚠️ AI Server konnte nicht gestartet werden:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle/Context global hinterlegen, damit der Server später neu gestartet
|
|
||||||
// werden kann (z.B. nach einem neuen Modell).
|
|
||||||
aiServerMu.Lock()
|
|
||||||
aiServerCtx = appCtx
|
|
||||||
aiServerCurrent = aiProc
|
|
||||||
watchedTrainingRoot := aiServerRoot
|
|
||||||
aiServerMu.Unlock()
|
|
||||||
|
|
||||||
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
|
|
||||||
|
|
||||||
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
|
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
|
||||||
resetAutostartPauseOnStartup()
|
resetAutostartPauseOnStartup()
|
||||||
|
|
||||||
@ -1132,6 +1127,26 @@ func main() {
|
|||||||
serverErrCh <- nil
|
serverErrCh <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
aiProc, err := startAIServer(appCtx)
|
||||||
|
if err != nil {
|
||||||
|
appLogln("⚠️ AI Server konnte nicht gestartet werden:", err)
|
||||||
|
}
|
||||||
|
if appCtx.Err() != nil {
|
||||||
|
if aiProc != nil {
|
||||||
|
aiProc.Stop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aiServerMu.Lock()
|
||||||
|
aiServerCurrent = aiProc
|
||||||
|
watchedTrainingRoot := aiServerRoot
|
||||||
|
aiServerMu.Unlock()
|
||||||
|
|
||||||
|
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
|
||||||
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_ = showAppNotification(
|
_ = showAppNotification(
|
||||||
"Recorder gestartet",
|
"Recorder gestartet",
|
||||||
|
|||||||
@ -14,8 +14,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RecorderSettings struct {
|
type RecorderSettings struct {
|
||||||
DatabaseType string `json:"databaseType"` // sqlite | postgres
|
DatabaseType string `json:"databaseType"`
|
||||||
SQLitePath string `json:"sqlitePath"`
|
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)
|
||||||
|
|
||||||
@ -67,8 +71,12 @@ type RecorderSettings struct {
|
|||||||
var (
|
var (
|
||||||
settingsMu sync.Mutex
|
settingsMu sync.Mutex
|
||||||
settings = RecorderSettings{
|
settings = RecorderSettings{
|
||||||
DatabaseType: databaseTypeSQLite,
|
DatabaseType: databaseTypePostgres,
|
||||||
SQLitePath: defaultSQLitePath,
|
PostgresMode: postgresModeURL,
|
||||||
|
PostgresDataDir: defaultPostgresDataDir,
|
||||||
|
PostgresPort: defaultPostgresPort,
|
||||||
|
PostgresDatabase: defaultPostgresDatabase,
|
||||||
|
PostgresUser: defaultPostgresUser,
|
||||||
DatabaseURL: "",
|
DatabaseURL: "",
|
||||||
EncryptedDBPassword: "",
|
EncryptedDBPassword: "",
|
||||||
|
|
||||||
@ -165,7 +173,7 @@ func loadSettings() {
|
|||||||
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.DatabaseType = normalizeDatabaseType(s.DatabaseType)
|
||||||
s.SQLitePath = normalizeSQLitePath(s.SQLitePath)
|
normalizePostgresSettings(&s)
|
||||||
if strings.TrimSpace(s.FFmpegPath) != "" {
|
if strings.TrimSpace(s.FFmpegPath) != "" {
|
||||||
s.FFmpegPath = strings.TrimSpace(s.FFmpegPath)
|
s.FFmpegPath = strings.TrimSpace(s.FFmpegPath)
|
||||||
}
|
}
|
||||||
@ -213,7 +221,7 @@ func loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
|
s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
|
||||||
s.SQLitePath = normalizeSQLitePath(s.SQLitePath)
|
normalizePostgresSettings(&s)
|
||||||
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)
|
||||||
@ -284,7 +292,11 @@ type RecorderSettingsPublic struct {
|
|||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
|
|
||||||
DatabaseType string `json:"databaseType"`
|
DatabaseType string `json:"databaseType"`
|
||||||
SQLitePath string `json:"sqlitePath"`
|
PostgresMode string `json:"postgresMode"`
|
||||||
|
PostgresDataDir string `json:"postgresDataDir"`
|
||||||
|
PostgresPort int `json:"postgresPort"`
|
||||||
|
PostgresDatabase string `json:"postgresDatabase"`
|
||||||
|
PostgresUser string `json:"postgresUser"`
|
||||||
DatabaseURL string `json:"databaseUrl"`
|
DatabaseURL string `json:"databaseUrl"`
|
||||||
HasDBPassword bool `json:"hasDbPassword"`
|
HasDBPassword bool `json:"hasDbPassword"`
|
||||||
|
|
||||||
@ -324,13 +336,18 @@ type RecorderSettingsPublic struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||||
|
normalizePostgresSettings(&s)
|
||||||
return RecorderSettingsPublic{
|
return RecorderSettingsPublic{
|
||||||
RecordDir: s.RecordDir,
|
RecordDir: s.RecordDir,
|
||||||
DoneDir: s.DoneDir,
|
DoneDir: s.DoneDir,
|
||||||
FFmpegPath: s.FFmpegPath,
|
FFmpegPath: s.FFmpegPath,
|
||||||
|
|
||||||
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
DatabaseType: normalizeDatabaseType(s.DatabaseType),
|
||||||
SQLitePath: normalizeSQLitePath(s.SQLitePath),
|
PostgresMode: s.PostgresMode,
|
||||||
|
PostgresDataDir: s.PostgresDataDir,
|
||||||
|
PostgresPort: s.PostgresPort,
|
||||||
|
PostgresDatabase: s.PostgresDatabase,
|
||||||
|
PostgresUser: s.PostgresUser,
|
||||||
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
|
||||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||||
|
|
||||||
@ -472,8 +489,12 @@ 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.DatabaseType = normalizeDatabaseType(in.DatabaseType)
|
||||||
in.SQLitePath = normalizeSQLitePath(in.SQLitePath)
|
normalizePostgresSettings(&in.RecorderSettings)
|
||||||
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
|
||||||
|
if in.PostgresMode == postgresModeURL && in.DatabaseURL == "" {
|
||||||
|
http.Error(w, "Database URL fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
|
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
|
||||||
// und URL ohne Passwort zurückschreiben.
|
// und URL ohne Passwort zurückschreiben.
|
||||||
@ -514,7 +535,11 @@ 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.SQLitePath = in.SQLitePath
|
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
|
||||||
|
|
||||||
@ -554,7 +579,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
dbChanged :=
|
dbChanged :=
|
||||||
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
||||||
normalizeSQLitePath(next.SQLitePath) != normalizeSQLitePath(current.SQLitePath) ||
|
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)
|
||||||
|
|
||||||
@ -562,21 +587,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DB erst prüfen, BEVOR gespeichert wird
|
// DB erst prüfen, BEVOR gespeichert wird
|
||||||
if dbChanged {
|
if dbChanged {
|
||||||
sqliteMoved := false
|
|
||||||
if sqlitePathChanged(current, next) {
|
|
||||||
if err := moveSQLiteDatabaseForSettings(current, next); err != nil {
|
|
||||||
http.Error(w, "SQLite-Datei konnte nicht verschoben werden: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sqliteMoved = true
|
|
||||||
}
|
|
||||||
|
|
||||||
storeConfig, err := buildModelStoreConfigFromRecorderSettings(next)
|
storeConfig, err := buildModelStoreConfigFromRecorderSettings(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if sqliteMoved {
|
|
||||||
_ = moveSQLiteDatabaseForSettings(next, current)
|
|
||||||
restoreModelStoreForSettings(current)
|
|
||||||
}
|
|
||||||
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -584,10 +596,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
newStore = NewModelStore(storeConfig)
|
newStore = NewModelStore(storeConfig)
|
||||||
if err := newStore.Load(); err != nil {
|
if err := newStore.Load(); err != nil {
|
||||||
_ = newStore.Close()
|
_ = newStore.Close()
|
||||||
if sqliteMoved {
|
|
||||||
_ = moveSQLiteDatabaseForSettings(next, current)
|
|
||||||
restoreModelStoreForSettings(current)
|
|
||||||
}
|
|
||||||
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -642,8 +650,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 != "sqlite" {
|
if target != "record" && target != "done" && target != "ffmpeg" && target != "postgresData" {
|
||||||
http.Error(w, "target muss record, done, ffmpeg oder sqlite sein", http.StatusBadRequest)
|
http.Error(w, "target muss record, done, ffmpeg oder postgresData sein", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,9 +665,9 @@ 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 == "sqlite" {
|
} else if target == "postgresData" {
|
||||||
p, err = dialog.Directory().
|
p, err = dialog.Directory().
|
||||||
Title("SQLite-Speicherort auswaehlen").
|
Title("PostgreSQL-Datenordner auswaehlen").
|
||||||
Browse()
|
Browse()
|
||||||
} else {
|
} else {
|
||||||
// Ordnerauswahl für record/done
|
// Ordnerauswahl für record/done
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -127,15 +126,10 @@ func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeConfig, err := buildModelStoreConfigFromSettings()
|
if _, err := buildModelStoreConfigFromSettings(); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if storeConfig.Type == databaseTypeSQLite {
|
|
||||||
settingsSQLiteDatabaseBackupHandler(w, r, storeConfig)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, _, err := postgresToolConfigFromSettings()
|
cfg, _, err := postgresToolConfigFromSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -200,7 +194,7 @@ func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
f, err := os.Open(tmpPath)
|
f, err := os.Open(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Backup-Datei konnte nicht geöffnet werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
@ -214,66 +208,13 @@ 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 geöffnet 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 geöffnet 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
|
||||||
}
|
}
|
||||||
|
|
||||||
storeConfig, err := buildModelStoreConfigFromSettings()
|
if _, err := buildModelStoreConfigFromSettings(); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -317,27 +258,6 @@ 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()
|
cfg, dsn, err := postgresToolConfigFromSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@ -366,7 +286,7 @@ func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
refreshedStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: 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 {
|
||||||
setModelStore(refreshedStore)
|
setModelStore(refreshedStore)
|
||||||
}
|
}
|
||||||
@ -377,88 +297,3 @@ 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, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,242 +1,119 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type databaseMigrationRequest struct {
|
|
||||||
DatabaseType string `json:"databaseType"`
|
|
||||||
SQLitePath string `json:"sqlitePath"`
|
|
||||||
DatabaseURL string `json:"databaseUrl"`
|
|
||||||
DBPassword string `json:"dbPassword,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelMigrationColumn struct {
|
|
||||||
name string
|
|
||||||
kind string
|
|
||||||
notNull bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
modelMigrationText = "text"
|
|
||||||
modelMigrationBool = "bool"
|
|
||||||
modelMigrationTime = "time"
|
|
||||||
modelMigrationBlob = "blob"
|
|
||||||
)
|
|
||||||
|
|
||||||
var modelMigrationColumns = []modelMigrationColumn{
|
|
||||||
{name: "id", kind: modelMigrationText, notNull: true},
|
|
||||||
{name: "input", kind: modelMigrationText, notNull: true},
|
|
||||||
{name: "is_url", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "host", kind: modelMigrationText},
|
|
||||||
{name: "path", kind: modelMigrationText},
|
|
||||||
{name: "model_key", kind: modelMigrationText, notNull: true},
|
|
||||||
{name: "tags", kind: modelMigrationText},
|
|
||||||
{name: "last_stream", kind: modelMigrationTime},
|
|
||||||
{name: "last_seen_online", kind: modelMigrationBool},
|
|
||||||
{name: "last_seen_online_at", kind: modelMigrationTime},
|
|
||||||
{name: "cb_online_json", kind: modelMigrationText},
|
|
||||||
{name: "cb_online_fetched_at", kind: modelMigrationTime},
|
|
||||||
{name: "cb_online_last_error", kind: modelMigrationText},
|
|
||||||
{name: "profile_image_url", kind: modelMigrationText},
|
|
||||||
{name: "profile_image_mime", kind: modelMigrationText},
|
|
||||||
{name: "profile_image_path", kind: modelMigrationText},
|
|
||||||
{name: "profile_image_hash", kind: modelMigrationText},
|
|
||||||
{name: "profile_image_blob", kind: modelMigrationBlob},
|
|
||||||
{name: "profile_image_updated_at", kind: modelMigrationTime},
|
|
||||||
{name: "room_status", kind: modelMigrationText},
|
|
||||||
{name: "is_online", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "chat_room_url", kind: modelMigrationText},
|
|
||||||
{name: "image_url", kind: modelMigrationText},
|
|
||||||
{name: "last_online_at", kind: modelMigrationTime},
|
|
||||||
{name: "last_offline_at", kind: modelMigrationTime},
|
|
||||||
{name: "last_room_sync_at", kind: modelMigrationTime},
|
|
||||||
{name: "biocontext_json", kind: modelMigrationText},
|
|
||||||
{name: "biocontext_fetched_at", kind: modelMigrationTime},
|
|
||||||
{name: "watching", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "favorite", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "hot", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "keep", kind: modelMigrationBool, notNull: true},
|
|
||||||
{name: "liked", kind: modelMigrationBool},
|
|
||||||
{name: "created_at", kind: modelMigrationTime, notNull: true},
|
|
||||||
{name: "updated_at", kind: modelMigrationTime, notNull: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbNullBool struct {
|
|
||||||
sql.NullBool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *dbNullBool) Scan(value any) error {
|
|
||||||
if value == nil {
|
|
||||||
b.Valid = false
|
|
||||||
b.Bool = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := value.(type) {
|
|
||||||
case bool:
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = v
|
|
||||||
return nil
|
|
||||||
case int64:
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = v != 0
|
|
||||||
return nil
|
|
||||||
case int:
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = v != 0
|
|
||||||
return nil
|
|
||||||
case []byte:
|
|
||||||
return b.scanString(string(v))
|
|
||||||
case string:
|
|
||||||
return b.scanString(v)
|
|
||||||
default:
|
|
||||||
var raw sql.NullBool
|
|
||||||
if err := raw.Scan(value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
b.NullBool = raw
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *dbNullBool) scanString(value string) error {
|
|
||||||
value = strings.TrimSpace(strings.ToLower(value))
|
|
||||||
if value == "" {
|
|
||||||
b.Valid = false
|
|
||||||
b.Bool = false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch value {
|
|
||||||
case "1", "t", "true", "yes", "y", "on":
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = true
|
|
||||||
return nil
|
|
||||||
case "0", "f", "false", "no", "n", "off":
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = false
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
parsed, err := strconv.ParseBool(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid bool value %q", value)
|
|
||||||
}
|
|
||||||
b.Valid = true
|
|
||||||
b.Bool = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelMigrationValue struct {
|
|
||||||
column modelMigrationColumn
|
|
||||||
text sql.NullString
|
|
||||||
bool dbNullBool
|
|
||||||
tm dbNullTime
|
|
||||||
blob []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *modelMigrationValue) dest() any {
|
|
||||||
switch v.column.kind {
|
|
||||||
case modelMigrationBool:
|
|
||||||
return &v.bool
|
|
||||||
case modelMigrationTime:
|
|
||||||
return &v.tm
|
|
||||||
case modelMigrationBlob:
|
|
||||||
return &v.blob
|
|
||||||
default:
|
|
||||||
return &v.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v modelMigrationValue) arg(now time.Time) any {
|
|
||||||
switch v.column.kind {
|
|
||||||
case modelMigrationBool:
|
|
||||||
if !v.bool.Valid {
|
|
||||||
if v.column.notNull {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v.bool.Bool
|
|
||||||
case modelMigrationTime:
|
|
||||||
if !v.tm.Valid || v.tm.Time.IsZero() {
|
|
||||||
if v.column.notNull {
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v.tm.Time.UTC()
|
|
||||||
case modelMigrationBlob:
|
|
||||||
if len(v.blob) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v.blob
|
|
||||||
default:
|
|
||||||
if !v.text.Valid {
|
|
||||||
if v.column.notNull {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v.text.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v modelMigrationValue) textValue() string {
|
|
||||||
if !v.text.Valid {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(v.text.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
func settingsDatabaseMigrateHandler(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
|
||||||
}
|
}
|
||||||
|
|
||||||
var req databaseMigrationRequest
|
var in RecorderSettingsIn
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
current := getSettings()
|
current := getSettings()
|
||||||
target, err := databaseMigrationTargetSettings(current, req)
|
target := current
|
||||||
if err != nil {
|
target.DatabaseType = databaseTypePostgres
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceConfig, err := buildModelStoreConfigFromRecorderSettings(current)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetConfig, err := buildModelStoreConfigFromRecorderSettings(target)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := migrateModelStoreData(sourceConfig, targetConfig)
|
tmp, err := os.CreateTemp("", "nsfwapp-db-migrate-*.dump")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Migration fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshedStore := NewModelStore(targetConfig)
|
newStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: targetDSN})
|
||||||
if err := refreshedStore.Load(); err != nil {
|
if err := newStore.Load(); err != nil {
|
||||||
http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geöffnet werden: "+err.Error(), http.StatusInternalServerError)
|
_ = newStore.Close()
|
||||||
|
http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,186 +121,77 @@ func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
settings = target
|
settings = target
|
||||||
settingsMu.Unlock()
|
settingsMu.Unlock()
|
||||||
saveSettingsToDisk()
|
saveSettingsToDisk()
|
||||||
setModelStore(refreshedStore)
|
|
||||||
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"rows": count,
|
"bytes": bytesWritten,
|
||||||
"sourceType": sourceConfig.Type,
|
"mode": target.PostgresMode,
|
||||||
"targetType": targetConfig.Type,
|
|
||||||
"targetSQLite": targetConfig.SQLitePath,
|
|
||||||
"targetDatabase": sanitizeDSNForLog(targetConfig.DSN),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseMigrationTargetSettings(current RecorderSettings, req databaseMigrationRequest) (RecorderSettings, error) {
|
func dumpPostgresDatabase(ctx context.Context, dbURL string, env []string, outPath string) error {
|
||||||
next := current
|
toolPath, err := postgresToolPath("pg_dump")
|
||||||
next.DatabaseType = normalizeDatabaseType(req.DatabaseType)
|
|
||||||
next.SQLitePath = normalizeSQLitePath(req.SQLitePath)
|
|
||||||
next.DatabaseURL = strings.TrimSpace(req.DatabaseURL)
|
|
||||||
|
|
||||||
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(next.DatabaseURL)
|
|
||||||
pwFromURL = strings.TrimSpace(pwFromURL)
|
|
||||||
if pwFromURL == "****" {
|
|
||||||
pwFromURL = ""
|
|
||||||
}
|
|
||||||
if sanitizedURL != "" {
|
|
||||||
next.DatabaseURL = sanitizedURL
|
|
||||||
}
|
|
||||||
|
|
||||||
plainPW := strings.TrimSpace(req.DBPassword)
|
|
||||||
if plainPW == "" {
|
|
||||||
plainPW = pwFromURL
|
|
||||||
}
|
|
||||||
if plainPW != "" {
|
|
||||||
enc, err := encryptSettingString(plainPW)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RecorderSettings{}, appErrorf("konnte DB-Passwort nicht verschluesseln: %w", err)
|
return err
|
||||||
}
|
|
||||||
next.EncryptedDBPassword = enc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if next.DatabaseType == databaseTypePostgres && strings.TrimSpace(next.DatabaseURL) == "" {
|
out, err := os.Create(outPath)
|
||||||
return RecorderSettings{}, errors.New("Database URL fehlt")
|
|
||||||
}
|
|
||||||
if next.DatabaseType == databaseTypeSQLite && strings.TrimSpace(next.SQLitePath) == "" {
|
|
||||||
return RecorderSettings{}, errors.New("SQLite-Datei fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
return next, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateModelStoreData(sourceConfig, targetConfig ModelStoreConfig) (int64, error) {
|
|
||||||
if sameModelStoreConfig(sourceConfig, targetConfig) {
|
|
||||||
return 0, errors.New("Quelle und Ziel sind identisch")
|
|
||||||
}
|
|
||||||
|
|
||||||
source := NewModelStore(sourceConfig)
|
|
||||||
if err := source.Load(); err != nil {
|
|
||||||
return 0, appErrorf("Quelle konnte nicht geöffnet werden: %w", err)
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
target := NewModelStore(targetConfig)
|
|
||||||
if err := target.Load(); err != nil {
|
|
||||||
return 0, appErrorf("Ziel konnte nicht geöffnet werden: %w", err)
|
|
||||||
}
|
|
||||||
defer target.Close()
|
|
||||||
|
|
||||||
count, err := migrateModelsTable(source.db, target.db)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return err
|
||||||
}
|
|
||||||
if normalizeDatabaseType(sourceConfig.Type) == databaseTypeSQLite &&
|
|
||||||
normalizeDatabaseType(targetConfig.Type) == databaseTypeSQLite &&
|
|
||||||
!sameCleanPath(filepath.Dir(sourceConfig.SQLitePath), filepath.Dir(targetConfig.SQLitePath)) {
|
|
||||||
sourceImageDir := sqliteProfileImageDir(sourceConfig.SQLitePath)
|
|
||||||
targetImageDir := sqliteProfileImageDir(targetConfig.SQLitePath)
|
|
||||||
if _, statErr := os.Stat(sourceImageDir); statErr == nil {
|
|
||||||
if _, targetErr := os.Stat(targetImageDir); errors.Is(targetErr, os.ErrNotExist) {
|
|
||||||
if copyErr := copyDirectory(sourceImageDir, targetImageDir); copyErr != nil {
|
|
||||||
return count, copyErr
|
|
||||||
}
|
|
||||||
} else if targetErr != nil {
|
|
||||||
return count, targetErr
|
|
||||||
}
|
|
||||||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
|
||||||
return count, statErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameModelStoreConfig(a, b ModelStoreConfig) bool {
|
cmd := exec.CommandContext(
|
||||||
a.Type = normalizeDatabaseType(a.Type)
|
ctx,
|
||||||
b.Type = normalizeDatabaseType(b.Type)
|
toolPath,
|
||||||
if a.Type != b.Type {
|
"--format=custom",
|
||||||
return false
|
"--blobs",
|
||||||
}
|
"--no-owner",
|
||||||
if a.Type == databaseTypeSQLite {
|
"--no-privileges",
|
||||||
return filepath.Clean(strings.TrimSpace(a.SQLitePath)) == filepath.Clean(strings.TrimSpace(b.SQLitePath))
|
"--dbname="+dbURL,
|
||||||
}
|
|
||||||
return strings.TrimSpace(a.DSN) == strings.TrimSpace(b.DSN)
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateModelsTable(sourceDB, targetDB *sql.DB) (int64, error) {
|
|
||||||
columnNames := make([]string, 0, len(modelMigrationColumns))
|
|
||||||
for _, column := range modelMigrationColumns {
|
|
||||||
columnNames = append(columnNames, column.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := sourceDB.Query("SELECT " + strings.Join(columnNames, ",") + " FROM models ORDER BY updated_at ASC, id ASC;")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
tx, err := targetDB.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
if _, err := tx.Exec(`DELETE FROM models;`); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
insertSQL := modelMigrationInsertSQL(columnNames)
|
|
||||||
stmt, err := tx.Prepare(insertSQL)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]modelMigrationValue, len(modelMigrationColumns))
|
|
||||||
dests := make([]any, len(modelMigrationColumns))
|
|
||||||
for i, column := range modelMigrationColumns {
|
|
||||||
values[i] = modelMigrationValue{column: column}
|
|
||||||
dests[i] = values[i].dest()
|
|
||||||
}
|
|
||||||
if err := rows.Scan(dests...); err != nil {
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
if values[0].textValue() == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
args := make([]any, len(values))
|
|
||||||
for i, value := range values {
|
|
||||||
args[i] = value.arg(now)
|
|
||||||
}
|
|
||||||
if _, err := stmt.Exec(args...); err != nil {
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelMigrationInsertSQL(columnNames []string) string {
|
|
||||||
placeholders := make([]string, 0, len(columnNames))
|
|
||||||
updates := make([]string, 0, len(columnNames)-1)
|
|
||||||
for i, column := range columnNames {
|
|
||||||
placeholders = append(placeholders, fmt.Sprintf("$%d", i+1))
|
|
||||||
if column != "id" {
|
|
||||||
updates = append(updates, column+"=EXCLUDED."+column)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"INSERT INTO models (%s) VALUES (%s) ON CONFLICT(id) DO UPDATE SET %s;",
|
|
||||||
strings.Join(columnNames, ","),
|
|
||||||
strings.Join(placeholders, ","),
|
|
||||||
strings.Join(updates, ","),
|
|
||||||
)
|
)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sqlitePathChanged(current, next RecorderSettings) bool {
|
|
||||||
return normalizeDatabaseType(current.DatabaseType) == databaseTypeSQLite &&
|
|
||||||
normalizeDatabaseType(next.DatabaseType) == databaseTypeSQLite &&
|
|
||||||
normalizeSQLitePath(current.SQLitePath) != normalizeSQLitePath(next.SQLitePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveSQLiteDatabaseForSettings(current, next RecorderSettings) error {
|
|
||||||
currentConfig, err := buildModelStoreConfigFromRecorderSettings(current)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nextConfig, err := buildModelStoreConfigFromRecorderSettings(next)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return moveSQLiteDatabaseFiles(currentConfig.SQLitePath, nextConfig.SQLitePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreModelStoreForSettings(rec RecorderSettings) {
|
|
||||||
config, err := buildModelStoreConfigFromRecorderSettings(rec)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
store := NewModelStore(config)
|
|
||||||
if err := store.Load(); err != nil {
|
|
||||||
_ = store.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setModelStore(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveSQLiteDatabaseFiles(sourcePath, targetPath string) error {
|
|
||||||
sourcePath = filepath.Clean(strings.TrimSpace(sourcePath))
|
|
||||||
targetPath = filepath.Clean(strings.TrimSpace(targetPath))
|
|
||||||
if sourcePath == "" || targetPath == "" {
|
|
||||||
return errors.New("sqlite source/target path fehlt")
|
|
||||||
}
|
|
||||||
if sourcePath == targetPath {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceInfo, err := os.Stat(sourcePath)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if sourceInfo.IsDir() {
|
|
||||||
return appErrorf("sqlite source path ist ein ordner: %s", sourcePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range append([]string{targetPath}, sqliteSidecarPaths(targetPath)...) {
|
|
||||||
if _, err := os.Stat(target); err == nil {
|
|
||||||
return appErrorf("Ziel-Datei existiert bereits: %s", target)
|
|
||||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceImageDir := sqliteProfileImageDir(sourcePath)
|
|
||||||
targetImageDir := sqliteProfileImageDir(targetPath)
|
|
||||||
if !sameCleanPath(sourceImageDir, targetImageDir) {
|
|
||||||
if _, err := os.Stat(sourceImageDir); err == nil {
|
|
||||||
if _, err := os.Stat(targetImageDir); err == nil {
|
|
||||||
return appErrorf("Ziel-Profilbildordner existiert bereits: %s", targetImageDir)
|
|
||||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if store := getModelStore(); store != nil && store.isSQLite() && store.db != nil {
|
|
||||||
_, _ = store.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
|
|
||||||
_ = store.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := moveDatabaseFile(sourcePath, targetPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSidecars := sqliteSidecarPaths(sourcePath)
|
|
||||||
targetSidecars := sqliteSidecarPaths(targetPath)
|
|
||||||
for i, source := range sourceSidecars {
|
|
||||||
if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := moveDatabaseFile(source, targetSidecars[i]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !sameCleanPath(sourceImageDir, targetImageDir) {
|
|
||||||
if err := moveSQLiteProfileImageDir(sourceImageDir, targetImageDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqliteProfileImageDir(sqlitePath string) string {
|
|
||||||
return filepath.Join(filepath.Dir(filepath.Clean(strings.TrimSpace(sqlitePath))), modelProfileImageDirName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameCleanPath(a, b string) bool {
|
|
||||||
return strings.EqualFold(filepath.Clean(strings.TrimSpace(a)), filepath.Clean(strings.TrimSpace(b)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveSQLiteProfileImageDir(sourceDir, targetDir string) error {
|
|
||||||
sourceDir = filepath.Clean(strings.TrimSpace(sourceDir))
|
|
||||||
targetDir = filepath.Clean(strings.TrimSpace(targetDir))
|
|
||||||
if sourceDir == "" || targetDir == "" || sameCleanPath(sourceDir, targetDir) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(sourceDir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return appErrorf("Profilbildpfad ist kein Ordner: %s", sourceDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := renameWithRetry(sourceDir, targetDir); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := copyDirectory(sourceDir, targetDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.RemoveAll(sourceDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyDirectory(sourceDir, targetDir string) error {
|
|
||||||
sourceDir = filepath.Clean(strings.TrimSpace(sourceDir))
|
|
||||||
targetDir = filepath.Clean(strings.TrimSpace(targetDir))
|
|
||||||
return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(sourceDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
target := filepath.Join(targetDir, rel)
|
|
||||||
if entry.IsDir() {
|
|
||||||
return os.MkdirAll(target, 0755)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = copyDatabaseFile(path, target)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqliteSidecarPaths(path string) []string {
|
|
||||||
return []string{
|
|
||||||
path + "-wal",
|
|
||||||
path + "-shm",
|
|
||||||
path + "-journal",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveDatabaseFile(sourcePath, targetPath string) error {
|
|
||||||
if err := renameWithRetry(sourcePath, targetPath); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := copyDatabaseFile(sourcePath, targetPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return removeWithRetry(sourcePath)
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
|
||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import ProgressBar from './ProgressBar'
|
||||||
import LoginShell from './login/LoginShell'
|
import LoginShell from './login/LoginShell'
|
||||||
import ErrorAlert from './login/ErrorAlert'
|
import ErrorAlert from './login/ErrorAlert'
|
||||||
import LoginForm from './login/LoginForm'
|
import LoginForm from './login/LoginForm'
|
||||||
@ -40,6 +41,18 @@ type MeResp = {
|
|||||||
passkeyCount?: number
|
passkeyCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MLSetupStatus = {
|
||||||
|
required?: boolean
|
||||||
|
running?: boolean
|
||||||
|
done?: boolean
|
||||||
|
ok?: boolean
|
||||||
|
progress?: number
|
||||||
|
phase?: string
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
logFile?: string
|
||||||
|
}
|
||||||
|
|
||||||
type SetupResp = {
|
type SetupResp = {
|
||||||
secret?: string
|
secret?: string
|
||||||
otpauth?: string
|
otpauth?: string
|
||||||
@ -55,6 +68,65 @@ function unwrapWebAuthnOptions<T = any>(options: any): T {
|
|||||||
return unwrapped as T
|
return unwrapped as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MLSetupModal({ status }: { status: MLSetupStatus | null }) {
|
||||||
|
if (!status?.required) return null
|
||||||
|
|
||||||
|
const progress = Math.max(0, Math.min(100, Number(status.progress) || 0))
|
||||||
|
const failed = Boolean(status.done && !status.ok)
|
||||||
|
const done = Boolean(status.done && status.ok)
|
||||||
|
if (done) return null
|
||||||
|
|
||||||
|
const title = failed
|
||||||
|
? 'ML-Python-Setup fehlgeschlagen'
|
||||||
|
: 'ML-Python-Pakete werden installiert'
|
||||||
|
const message = String(status.message || '').trim() || 'Die ML-Python-Umgebung wird vorbereitet.'
|
||||||
|
const detail = failed
|
||||||
|
? String(status.error || '').trim()
|
||||||
|
: status.logFile
|
||||||
|
? `Log: ${status.logFile}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 grid place-items-center bg-white/80 p-4 backdrop-blur-sm dark:bg-gray-950/80"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'w-full rounded-xl border p-4 shadow-lg',
|
||||||
|
failed
|
||||||
|
? 'border-red-200 bg-red-50 text-red-950 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-100'
|
||||||
|
: 'border-indigo-200 bg-white text-gray-900 dark:border-indigo-400/30 dark:bg-gray-900 dark:text-gray-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold">{title}</div>
|
||||||
|
<div className="mt-1 break-words text-xs opacity-80">{message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs font-semibold tabular-nums">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
className="mt-3"
|
||||||
|
value={progress}
|
||||||
|
showPercent={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<div className="mt-2 break-words text-xs opacity-75">
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage({ onLoggedIn }: Props) {
|
export default function LoginPage({ onLoggedIn }: Props) {
|
||||||
const nextPath = useMemo(() => getNextFromLocation(), [])
|
const nextPath = useMemo(() => getNextFromLocation(), [])
|
||||||
|
|
||||||
@ -77,6 +149,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
|
|
||||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||||
const [passkeyFeedback, setPasskeyFeedback] = useState<PasskeyFeedback | null>(null)
|
const [passkeyFeedback, setPasskeyFeedback] = useState<PasskeyFeedback | null>(null)
|
||||||
|
const [mlSetupStatus, setMLSetupStatus] = useState<MLSetupStatus | null>(null)
|
||||||
|
|
||||||
const submittedOnceRef = useRef(false)
|
const submittedOnceRef = useRef(false)
|
||||||
|
|
||||||
@ -107,6 +180,39 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/ml/status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const status = (await res.json()) as MLSetupStatus
|
||||||
|
if (cancelled) return
|
||||||
|
setMLSetupStatus(status)
|
||||||
|
|
||||||
|
if (status?.required && (!status.done || status.running)) {
|
||||||
|
timer = window.setTimeout(poll, 1000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
timer = window.setTimeout(poll, 2500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
function passkeyErrorMessage(e: any) {
|
function passkeyErrorMessage(e: any) {
|
||||||
const raw = String(e?.message ?? e ?? '').trim()
|
const raw = String(e?.message ?? e ?? '').trim()
|
||||||
|
|
||||||
@ -575,7 +681,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginShell>
|
<LoginShell modal={<MLSetupModal status={mlSetupStatus} />}>
|
||||||
{stage === 'login' ? (
|
{stage === 'login' ? (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
username={username}
|
username={username}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export default function LoginShell(props: { children: ReactNode }) {
|
export default function LoginShell(props: { modal?: ReactNode; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
||||||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
|
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
@ -11,7 +11,8 @@ export default function LoginShell(props: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative grid min-h-[100dvh] place-items-center px-4">
|
<div className="relative grid min-h-[100dvh] place-items-center px-4">
|
||||||
<div className="w-full max-w-md rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
<div className="w-full max-w-md">
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
|
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
@ -22,6 +23,9 @@ export default function LoginShell(props: { children: ReactNode }) {
|
|||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{props.modal}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user